@sporhq/spor 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,6 @@
2
2
  "name": "spor",
3
3
  "displayName": "Spor Context Compiler",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
- "version": "0.6.0",
5
+ "version": "0.7.0",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/API.md CHANGED
@@ -16,8 +16,13 @@ Every mutation is validated, attributed, serialized, and committed to the
16
16
  graph's git repo. What a client sees:
17
17
 
18
18
  - **Attribution**: the server stamps `author: <identity>` and
19
- `authored_via: mcp|rest|capture` from the authenticated token — any
20
- `author:` supplied in the payload is discarded.
19
+ `authored_via: mcp|rest|capture|dispatch` from the authenticated token — any
20
+ `author:` supplied in the payload is discarded. A write under an
21
+ **agent-scoped token** (§4) additionally stamps `authored_by_agent: <agent-id>`
22
+ and `session: <id>` and uses `authored_via: dispatch`, while `author:` stays
23
+ the agent's **owning person** — so the node reads "agent on behalf of person".
24
+ These ride-along fields are token-derived too; any supplied in the payload are
25
+ discarded.
21
26
  - **Create**: `if_exists: "skip"` → id collision is reported as `skipped`
22
27
  (the distiller default); `if_exists: "error"` → id collision is a
23
28
  `conflict` error.
@@ -291,7 +296,7 @@ endpoint is the REST twin of a core call:
291
296
  | `GET /v1/status` | session-start, monitoring | `{node_count, projects: {...}, head, uptime, metrics}`; doubles as the health check. `?titles=1` adds `titles: [{id, type, project, title}]` — the one-round-trip graph index the distiller dedups against |
292
297
  | `GET /v1/me` | `spor whoami`/`status`, onboarding | identity echo for the bearer token → `{person, name, email, bound, is_admin}`. `bound:false` means the token authenticates but maps to **no person node** (legacy/OAuth, or minted before the node existed), so routed questions and the personal queue will be empty — the client warns on it (the silent identity-degradation signal). `is_admin` reflects the `stewards→root` edge that gates the token-admin surface |
293
298
  | `GET /v1/briefing/{project}` | session-start | read the `brief-<project>` node → `{found, version, body, project_brief?, graph_status}`. The slug resolves through project-node aliases (GRAPH.md "Project identity nodes") before lookup. A BARE repo slug also rides up to its home-project grouping: the grouping's `brief-<grouping>` node returns alongside as `project_brief` (the product context spanning sibling repos), matching the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping); passing the repo NODE id (`repo-<slug>`) is the escape hatch that returns only the repo brief, no `project_brief`. Optional `?fp=root:<sha>,remote:<host/path>,...` carries the repo's fingerprints: the server learns them onto the owning project node, and an unknown slug with a known fingerprint files an alias proposal in the queue |
294
- | `POST /v1/digest` `{query, min_sim?}` | prompt-context | digest-mode compile → `{found, text}`; `found: false` is a successful empty result |
299
+ | `POST /v1/digest` `{query, root?, project?, min_sim?}` | prompt-context, /spor:brief | digest-mode compile → `{found, text}`; `found: false` is a successful empty result. `root` is the structural-walk twin of `query` (the two are mutually exclusive; `root` wins, an unknown id is `422`). Optional `project` is the session slug: the server scopes the compile to it — the same-project relevance boost, the grouping union, and the `always_on` norm `applies_to_*` ride-along — resolving the slug through project-node aliases/groupings inside compile (dec-spor-queue-slug-resolves-to-grouping), exactly as `/v1/queue` does. A bad slug is `422`; **omitting `project` runs the digest project-blind (byte-identical to before)**, so older clients that send only `{query}` are unaffected |
295
300
  | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; when a live inbound resolves/answers edge contradicts a still-open status the response carries `resolution`, and open gardener findings about the node ride along as `open_findings` |
296
301
  | `POST /v1/nodes` | drain-outbox, mechanical writers | `put_node` semantics, batch: `{nodes: [...], if_exists: "skip"}` (entries may be raw strings or `{node, if_exists, revision}`) → `{results: [...]}`, 207 when any entry failed |
297
302
  | `POST /v1/nodes/{id}/edges` `{type, to}` | scripts, mechanical writers | `add_edge` semantics (§1): normalize/flip, dedupe, append — no revision echo |
@@ -314,6 +319,9 @@ endpoint is the REST twin of a core call:
314
319
  | `GET /v1/admin/tokens` | offboarding / audit | list PATs → `{tokens: [{hash_prefix, person, name, email, created, expires, expired}], count}` — never plaintext, never full hashes. Admin-only (§4) |
315
320
  | `POST /v1/admin/tokens` `{person, expires?}` | onboarding | mint a PAT bound to an existing person node (`expires` is `<N>d` or an ISO date) → 201 `{token, hash_prefix, person, name, email, expires}`; the plaintext `token` is returned **once**. Admin-only |
316
321
  | `DELETE /v1/admin/tokens/{hash-prefix}` | offboarding / rotation | revoke the single PAT matching the hash prefix (≥8 hex chars; an ambiguous prefix is a 409) → `{revoked, hash_prefix}`. Admin-only |
322
+ | `GET /v1/agents` | `spor agent list` | list the agents the caller **owns** → `{agents: [{id, label, owner, spiffe, pubkey, status}], count}`; `?all=1` lists every agent (admin-only) |
323
+ | `POST /v1/admin/agents` `{label, owner?, id?, pubkey?}` | `spor agent create`, onboarding | create a person-owned `agent` node + its `owned-by` edge (`owner` defaults to the caller's person; `id` derives from `label`) → 201 `{id, owner, spiffe, pubkey, status, revision}`. 409 dup id / 422 invalid / 403 non-admin. Admin-only, same `stewards→root` gate as `/v1/admin/tokens` |
324
+ | `POST /v1/agents/{id}/token` `{session, audience?, expires?}` | `spor dispatch` | **self-serve** (NOT admin): mint a short-TTL, per-session token scoped to agent `{id}` → 201 `{token, expires_at, agent, session}`. Authorized iff the caller's person **owns** the agent (its `owned-by` edge) — else `403`; `404` unknown agent; `422` bad/missing `session`. The token carries `{agent, session}` (the person is derived from the `owned-by` edge at verify time); a write under it is stamped agent-on-behalf-of-person (§1). A caller `expires` may only shorten the default TTL, never extend it |
317
325
 
318
326
  Path parameters (node ids, project slugs) must match
319
327
  `^[a-z0-9][a-z0-9-]*$`. Request bodies are capped at 1MB
@@ -357,6 +365,19 @@ anything with a token.
357
365
  --admin --person <id>` (it writes that `stewards` edge, creating the person
358
366
  node from `--name`/`--email` if needed). Hand-editing the token file stays
359
367
  as the break-glass path.
368
+ - **Agent-scoped session tokens.** A person mints a short-lived, per-session
369
+ token for an `agent` they **own** through the self-serve
370
+ `POST /v1/agents/{id}/token` (§3) — authorized by ownership (the agent's
371
+ `owned-by → person` edge), never admin, so a dispatcher needs no special
372
+ privilege to run their own agents. The token's record carries `{agent,
373
+ session}` and no person; the owning person (and its `{name, email}`) resolves
374
+ from the `owned-by` edge at verify time, so a deleted agent or owner makes the
375
+ token fail closed rather than impersonate. Writes under it are attributed
376
+ agent-on-behalf-of-person (§1) — the `person → agent` chain is the audit
377
+ trail. `spor dispatch` mints one per run and injects it into the launched
378
+ background agent (so the agent's own graph writes carry its identity), picking
379
+ the machine's default agent from the `dispatch.agent` client config
380
+ (`SPOR_DISPATCH_AGENT`).
360
381
  - **OAuth 2.1 for MCP connectors** (Cowork/claude.ai, which cannot carry a
361
382
  static bearer token): protected-resource metadata discovery (RFC 9728,
362
383
  advertised on the `/mcp` 401 via `WWW-Authenticate`), authorization-server
package/GRAPH.md CHANGED
@@ -94,6 +94,7 @@ Rules:
94
94
  | correction | `corr-` | standing fix to a briefing: pin/exclude/guidance (never traversed) |
95
95
  | question | `question-` | a routed ask the graph could not answer (queueable; status `open`/`answered`, gated) |
96
96
  | person | `person-` | a member of the org — the identity anchor for `$viewer` binding and Tier-2 question routing (team mode; see "People, routing, and onboarding") |
97
+ | agent | `agent-` | a person-owned automation principal — a dispatched session's durable identity, owned by a person via an `owned-by` edge; its writes attribute "agent on behalf of person" (see "Agents") |
97
98
  | capture-pending | `cap-` | raw captured text that fit no schema; filed by the server for later triage (QUEUE.md §2.3); born status-less, closed only as `merged` (content now in proper node(s)) or `rejected` (no durable fact) — a `transitions()` gate rejects other statuses at write time |
98
99
  | finding | `find-` | a gardener observation about another node, filed as a queue item (QUEUE.md §6) |
99
100
  | repo | `repo-` | durable git-repo identity: slug aliases + repo fingerprints; heals renames at read time (below) |
@@ -555,6 +556,56 @@ client surfaces no warning when the authenticated identity maps to no person
555
556
  node. Surfacing that unbound state in queue and briefing responses is tracked
556
557
  server-side (issue-cc-onboarding-email-mismatch-silent-degradation).
557
558
 
559
+ ### Agents (person-owned principals)
560
+
561
+ An `agent` node (prefix `agent-`) is a person's automation principal — the
562
+ durable identity of a dispatched `claude --bg` session
563
+ (dec-spor-agent-identity-nodes). It generalizes the workflow-run principal: a
564
+ dispatched session is just another principal kind owned by a person, so work it
565
+ creates reads "agent **on behalf of** person" rather than person-direct.
566
+
567
+ ```markdown
568
+ ---
569
+ id: agent-anthony-laptop
570
+ type: agent
571
+ title: Anthony's laptop agent
572
+ summary: Dispatched-session principal on Anthony's laptop; owned by person-anthony.
573
+ spiffe: spiffe://spor.sporhq/person/person-anthony/agent/anthony-laptop
574
+ pubkey: ""
575
+ status: active
576
+ date: 2026-06-16
577
+ edges:
578
+ - {type: owned-by, to: person-anthony}
579
+ ---
580
+ ```
581
+
582
+ - **Grain: persistent, one per machine/install.** An agent node is created once
583
+ (`spor agent create <label>`, or the admin endpoint) and REUSED across every
584
+ dispatch — NOT one node per session. Each dispatch's Claude Code `session_id`
585
+ is the ephemeral which-run, recorded as an additive `session:` stamp on the
586
+ nodes it writes, not as its own node (promote to a `run-<id>` node only if run
587
+ metadata later needs a home).
588
+ - **`owned-by` is the ownership edge** (agent → person, inverse `owns`). Like
589
+ `grouped-under` it is a low-weight (0.3) structural identity binding, not a
590
+ work dependency — the owner is not pulled into the agent's work neighborhoods.
591
+ Ownership lives on this edge, NOT in frontmatter.
592
+ - **`spiffe:` / `pubkey:` are forward-compat shape, unenforced.** The `spiffe:`
593
+ URI encodes the binding in its path
594
+ (`spiffe://spor.<org>/person/<id>/agent/<label>`, extended to
595
+ `…/session/<uuid>` per dispatch); `pubkey:` records the agent's key
596
+ fingerprint (may be empty). Both adopt the SHAPE (dec-cc-spiffe-forward-compat)
597
+ — no signature verification, JWKS, or signed commits ship in this cut.
598
+ - **Attribution stamps (additive).** Work an agent creates keeps `author:` = the
599
+ owning person (so `$viewer`, routing, history, and the queue are unchanged) and
600
+ ADDS `authored_by_agent: agent-<id>`, `authored_via: dispatch`, and `session:
601
+ <id>`. These are token-derived, never from the payload
602
+ (dec-cc-attribution-from-token); old nodes lack them and read as person-direct.
603
+ This is purely additive — the stamp itself needs no `schema_version` bump.
604
+ - `capturable: false` (agent node and `owned-by` edge): both are created
605
+ deliberately, never drafted from a capture or distilled from a transcript —
606
+ mirroring `person`, `repo`, and `workflow-run`. (So the distiller's emit
607
+ vocabulary deliberately omits `agent`/`owned-by`.)
608
+
558
609
  ## Edge types and traversal weights
559
610
 
560
611
  | edge | weight | meaning |
@@ -574,6 +625,7 @@ server-side (issue-cc-onboarding-email-mismatch-silent-degradation).
574
625
  | `mentions` | 0.5 | weakest association |
575
626
  | `stewards` | 0.4 | this person stewards an area/spec/norm — the Tier-2 question-routing key |
576
627
  | `grouped-under` | 0.3 | this repo's home project grouping (inverse `groups`); structural membership, not work dependency |
628
+ | `owned-by` | 0.3 | this agent is owned by that person (inverse `owns`); structural identity binding, not work dependency |
577
629
  | `routed-to` | 0.3 | a question routed to this person for answering |
578
630
  | `review-requested` | 0.3 | a review of this node is requested of this person (pending) — surfaces in their queue |
579
631
  | `compiled-for` | — | briefing → its task/query (provenance only) |
package/bin/spor.js CHANGED
@@ -21,6 +21,7 @@ const fs = require("fs");
21
21
  const os = require("os");
22
22
  const path = require("path");
23
23
  const https = require("https");
24
+ const crypto = require("crypto");
24
25
  const { spawnSync } = require("child_process");
25
26
  const { parseArgs } = require("util");
26
27
 
@@ -945,6 +946,217 @@ async function cmdInvite(cfg, { values }) {
945
946
  return 0;
946
947
  }
947
948
 
949
+ // --- spor agent: a person-owned automation principal ----------------------
950
+ // dec-spor-agent-identity-nodes: an agent is a first-class `type: agent` node
951
+ // owned by a person via an `owned-by` edge, so a dispatched session's writes
952
+ // read "agent on behalf of person" instead of person-direct. One persistent
953
+ // node per machine/install, created once here and reused across dispatches.
954
+ //
955
+ // REMOTE: POST /v1/admin/agents creates the node + owned-by edge through the
956
+ // server's validated Store door, admin-gated like /v1/admin/people (the
957
+ // server is the CA, it mints the spiffe). FAIL-SOFT on 404 — the endpoint is
958
+ // landing in the spor-server stream; an old server gets a clear message, not
959
+ // a crash.
960
+ // LOCAL: write the agent node + owned-by edge to the graph home via the same
961
+ // lib/graph validate-before-write path cmdAdd uses; the spiffe is built
962
+ // client-side from a config `org` (forward-compat shape, unenforced).
963
+ async function cmdAgent(cfg, args) {
964
+ const sub = args[0];
965
+ if (sub === "create") {
966
+ const label = args[1];
967
+ if (!label || label.startsWith("-")) {
968
+ err("usage: spor agent create <label> [--owner person-x] [--pubkey <fp>]");
969
+ return 1;
970
+ }
971
+ const owner = optVal(args, "owner");
972
+ const pubkey = optVal(args, "pubkey") || "";
973
+ return cfg.mode() === "remote"
974
+ ? cmdAgentCreateRemote(cfg, { label, owner, pubkey })
975
+ : cmdAgentCreateLocal(cfg, { label, owner, pubkey });
976
+ }
977
+ if (!sub || sub === "list") {
978
+ return cfg.mode() === "remote" ? cmdAgentListRemote(cfg) : cmdAgentListLocal(cfg);
979
+ }
980
+ err("usage: spor agent create <label> [--owner person-x] [--pubkey <fp>] | spor agent list");
981
+ return 1;
982
+ }
983
+
984
+ async function cmdAgentCreateRemote(cfg, { label, owner, pubkey }) {
985
+ const body = { label };
986
+ if (owner) body.owner = owner;
987
+ if (pubkey) body.pubkey = pubkey;
988
+ const r = await remote.post(cfg, "/v1/admin/agents", body);
989
+ if (r.transport) {
990
+ err(`offline — could not reach server (${r.error})`);
991
+ return 1;
992
+ }
993
+ if (notAdminHint(r)) return 1;
994
+ if (r.status === 404) {
995
+ // The endpoint is part of the agent-identity rollout (the spor-server
996
+ // stream); an older server doesn't have it yet. Fail soft, don't crash.
997
+ err("this server has no agent-creation endpoint yet (POST /v1/admin/agents).");
998
+ err(" upgrade the Spor server, or create the agent in local mode against a checkout.");
999
+ return 1;
1000
+ }
1001
+ if (r.status === 409) {
1002
+ err(`agent already exists: ${(r.json && r.json.error && r.json.error.message) || "duplicate id"}`);
1003
+ return 1;
1004
+ }
1005
+ if (!r.ok) {
1006
+ err(`agent create failed (${r.status}): ${(r.json && r.json.error && r.json.error.message) || r.text}`);
1007
+ return 1;
1008
+ }
1009
+ const j = r.json || {};
1010
+ out(`created agent ${j.id || `agent-${kebab(label)}`}${j.owner ? ` owned by ${j.owner}` : ""}`);
1011
+ if (j.spiffe) out(` spiffe: ${j.spiffe}`);
1012
+ out(` dispatch with this agent: spor dispatch … (set dispatch.agent=${j.id || `agent-${kebab(label)}`} to make it this machine's default)`);
1013
+ return 0;
1014
+ }
1015
+
1016
+ // Build the agent node + owned-by edge locally. Owner defaults to a single
1017
+ // person node in the graph when unambiguous (the solo-local common case),
1018
+ // else it must be named — the binding is identity-load-bearing, never guessed.
1019
+ async function cmdAgentCreateLocal(cfg, { label, owner, pubkey }) {
1020
+ const graphLib = require(path.join(ROOT, "lib", "graph.js"));
1021
+ const nodesDir = cfg.nodesDir();
1022
+ if (!fs.existsSync(nodesDir)) {
1023
+ err(`no graph at ${nodesDir} — run 'spor init' first`);
1024
+ return 1;
1025
+ }
1026
+ let g;
1027
+ try {
1028
+ g = graphLib.loadGraph(nodesDir);
1029
+ } catch (e) {
1030
+ err(`could not load graph: ${e.message}`);
1031
+ return 1;
1032
+ }
1033
+ let ownerId = owner;
1034
+ if (!ownerId) {
1035
+ const people = Object.values(g.nodes || {}).filter((n) => n.type === "person");
1036
+ if (people.length === 1) {
1037
+ ownerId = people[0].id;
1038
+ } else if (people.length === 0) {
1039
+ err("no person node in the graph to own this agent — pass --owner person-x");
1040
+ err(" (an agent's owner is recorded as an owned-by edge to a person node).");
1041
+ return 1;
1042
+ } else {
1043
+ err(`several person nodes — name the owner with --owner (one of: ${people.map((p) => p.id).slice(0, 6).join(", ")}${people.length > 6 ? ", …" : ""})`);
1044
+ return 1;
1045
+ }
1046
+ } else if (!(g.nodes && g.nodes[ownerId])) {
1047
+ err(`no such person node: ${ownerId}`);
1048
+ return 1;
1049
+ }
1050
+
1051
+ const prefix = (g.registry && g.registry.prefixesFor("agent") || ["agent-"])[0] || "agent-";
1052
+ const id = `${prefix}${kebab(label)}`;
1053
+ if (fs.existsSync(path.join(nodesDir, `${id}.md`))) {
1054
+ err(`agent already exists: ${id}`);
1055
+ return 1;
1056
+ }
1057
+ // Forward-compat spiffe shape (dec-cc-spiffe-forward-compat): recorded, not
1058
+ // verified. <org> from config (default "local") so a solo graph is sensible.
1059
+ const org = cfg.get("org", null) || "local";
1060
+ const personLabel = ownerId.replace(/^person-/, "") || ownerId;
1061
+ const spiffe = `spiffe://spor.${org}/person/${personLabel}/agent/${kebab(label)}`;
1062
+ const md =
1063
+ `---\nid: ${id}\ntype: agent\ntitle: ${label.replace(/\n/g, " ")}\n` +
1064
+ `summary: Automation principal ${label}, owned by ${ownerId} — its dispatched-session writes read "agent on behalf of person".\n` +
1065
+ `spiffe: ${spiffe}\npubkey: ${pubkey.replace(/\n/g, " ")}\nstatus: active\ndate: ${today()}\n` +
1066
+ `edges:\n - {type: owned-by, to: ${ownerId}}\n---\n\n` +
1067
+ `Person-owned automation principal (dec-spor-agent-identity-nodes). Created by \`spor agent create\`; reused across dispatches as this machine's durable identity.\n`;
1068
+ let node;
1069
+ try {
1070
+ node = graphLib.parseFrontmatter(md, `${id}.md`);
1071
+ } catch (e) {
1072
+ err(`invalid node: ${e.message}`);
1073
+ return 1;
1074
+ }
1075
+ const v = graphLib.validateNode(g, node);
1076
+ if (!v.ok) {
1077
+ err(`invalid agent node:\n ${v.errors.join("\n ")}`);
1078
+ return 1;
1079
+ }
1080
+ fs.writeFileSync(path.join(nodesDir, `${id}.md`), md);
1081
+ out(`created agent ${id} owned by ${ownerId}`);
1082
+ out(` spiffe: ${spiffe}`);
1083
+ out(` dispatch with this agent: set dispatch.agent=${id} (spor repos config), then 'spor dispatch …'`);
1084
+ return 0;
1085
+ }
1086
+
1087
+ // List agent nodes. Remote: GET /v1/agents (the caller's owned agents — the
1088
+ // dedicated route). If that surface isn't deployed (404), fall back to projecting
1089
+ // the /v1/changes audit trail and keeping the type:agent rows (newest change per
1090
+ // node first, so the first row per id is the live one). Local: scan the graph
1091
+ // home. Fail-soft on any read error.
1092
+ async function cmdAgentListRemote(cfg) {
1093
+ const a = await remote.get(cfg, "/v1/agents", { timeoutMs: 6000 });
1094
+ if (a.transport) {
1095
+ err(`offline — could not reach server (${a.error})`);
1096
+ return 1;
1097
+ }
1098
+ if (a.ok && a.json && Array.isArray(a.json.agents)) {
1099
+ const rows = a.json.agents.map((ag) => `${ag.id}\t${ag.owner ? `owned-by ${ag.owner}` : (ag.title || "")}\t${ag.status || "active"}`);
1100
+ if (!rows.length) {
1101
+ out("no agents yet — create one with 'spor agent create <label>'");
1102
+ return 0;
1103
+ }
1104
+ rows.forEach((l) => out(l));
1105
+ return 0;
1106
+ }
1107
+ // /v1/agents not deployed yet — degrade to the audit-trail projection, which
1108
+ // every remote client already has.
1109
+ if (a.status === 404) {
1110
+ const q = await remote.get(cfg, "/v1/changes?limit=500", { timeoutMs: 6000 });
1111
+ if (q.ok && q.json && Array.isArray(q.json.changes)) {
1112
+ const seen = new Set();
1113
+ const rows = [];
1114
+ for (const c of q.json.changes) {
1115
+ if (!c || c.type !== "agent" || c.change === "deleted") continue;
1116
+ if (seen.has(c.id)) continue; // first (newest) wins
1117
+ seen.add(c.id);
1118
+ rows.push(`${c.id}\t${c.title || ""}`);
1119
+ }
1120
+ if (!rows.length) {
1121
+ out("no agents yet — create one with 'spor agent create <label>'");
1122
+ return 0;
1123
+ }
1124
+ rows.forEach((l) => out(l));
1125
+ return 0;
1126
+ }
1127
+ }
1128
+ err("could not list agents from this server (no /v1/agents or /v1/changes route).");
1129
+ err(" list them in local mode against a checkout, or upgrade the server.");
1130
+ return 1;
1131
+ }
1132
+
1133
+ function cmdAgentListLocal(cfg) {
1134
+ const graphLib = require(path.join(ROOT, "lib", "graph.js"));
1135
+ const nodesDir = cfg.nodesDir();
1136
+ if (!fs.existsSync(nodesDir)) {
1137
+ err(`no graph at ${nodesDir} — run 'spor init' first`);
1138
+ return 1;
1139
+ }
1140
+ let g;
1141
+ try {
1142
+ g = graphLib.loadGraph(nodesDir);
1143
+ } catch (e) {
1144
+ err(`could not load graph: ${e.message}`);
1145
+ return 1;
1146
+ }
1147
+ const agents = Object.values(g.nodes || {}).filter((n) => n.type === "agent");
1148
+ if (!agents.length) {
1149
+ out("no agents yet — create one with 'spor agent create <label>'");
1150
+ return 0;
1151
+ }
1152
+ for (const a of agents.sort((x, y) => x.id.localeCompare(y.id))) {
1153
+ const ownedBy = (a.edges || []).find((e) => e.type === "owned-by");
1154
+ const status = a.status || "active";
1155
+ out(`${a.id}\t${ownedBy ? `owned-by ${ownedBy.to}` : "(no owner)"}\t${status}`);
1156
+ }
1157
+ return 0;
1158
+ }
1159
+
948
1160
  async function cmdToken(cfg, args) {
949
1161
  if (cfg.mode() !== "remote") {
950
1162
  err("token admin needs a team graph (remote mode).");
@@ -1778,17 +1990,20 @@ async function topQueueItem(cfg, slug) {
1778
1990
  // post-tool heartbeat drives (dec-cc-task-claim-lease, task-cc-claim-nudge-hook).
1779
1991
  // REMOTE-MODE ONLY: a claim is a server-held lease; local mode has no pool or
1780
1992
  // contention (dec-cc-task-claim-lease "Local mode"), so the caller skips it and
1781
- // local dispatch stays byte-identical. No session is bound on purpose — the
1782
- // dispatching CLI session ends immediately, while the launched bg agent (a
1783
- // DIFFERENT session) keeps the lease alive: its post-tool hook renews any
1784
- // in_progress lease the person holds (assignee=me). Leaving the lease
1785
- // person-scoped (session:null) is what lets that cross-session heartbeat work;
1786
- // binding it to this short-lived session would orphan the heartbeat. Returns
1787
- // {ok} on success/idempotent-renew, {conflict, message} when the node is already
1788
- // held (the concurrent-dispatch case this guards), or {error} for any other
1789
- // failure (fail-open: the caller warns and dispatches anyway).
1790
- async function claimDispatch(cfg, nodeId) {
1791
- const r = await remote.post(cfg, `/v1/nodes/${encodeURIComponent(nodeId)}/claim`, {}, { timeoutMs: 6000 });
1993
+ // local dispatch stays byte-identical. SESSION-BOUND at dispatch
1994
+ // (dec-spor-session-identity-active-record): dispatch now FORCES the session id
1995
+ // (`--session-id <uuid>`), so it knows the working session up front and binds the
1996
+ // lease to it; the launched bg agent runs under that exact session, so its
1997
+ // post-tool heartbeat renews the same-session lease (the earlier person-scoped /
1998
+ // session:null workaround existed only because the old dispatcher did NOT know
1999
+ // the working session that reason is gone). Omitting `session` leaves the lease
2000
+ // person-scoped, the prior behavior. Returns {ok} on success/idempotent-renew,
2001
+ // {conflict, message} when the node is already held (the concurrent-dispatch case
2002
+ // this guards), or {error} for any other failure (fail-open: the caller warns and
2003
+ // dispatches anyway).
2004
+ async function claimDispatch(cfg, nodeId, session) {
2005
+ const body = session ? { session } : {};
2006
+ const r = await remote.post(cfg, `/v1/nodes/${encodeURIComponent(nodeId)}/claim`, body, { timeoutMs: 6000 });
1792
2007
  if (r.ok) return { ok: true, lease: r.json && r.json.lease };
1793
2008
  // 409 = the node can't be claimed right now — almost always a live lease held
1794
2009
  // by ANOTHER person (the concurrent-dispatch conflict this feature exists to
@@ -1887,6 +2102,100 @@ function onboardRepo(cfg, dir) {
1887
2102
  }
1888
2103
  }
1889
2104
 
2105
+ // --- dispatch agent identity (dec-spor-session-identity-active-record) -----
2106
+ // A dispatched session runs AS this machine's agent, carried on a per-session
2107
+ // agent-scoped MCP token (env does NOT propagate through `claude --bg`, so
2108
+ // identity rides the token in --mcp-config, never env). These three helpers are
2109
+ // the verified mechanism; all fail soft so a server without the agent surface,
2110
+ // or a machine with no agent configured, degrades to the prior person-scoped
2111
+ // dispatch with a clear line.
2112
+
2113
+ // This machine's agent node id, or null. A per-machine config key the shared
2114
+ // graph can't hold (like dispatch.repos) — SPOR_DISPATCH_AGENT / .spor.json
2115
+ // {"dispatch":{"agent":"agent-x"}} / user config. null => dispatch without
2116
+ // agent-scoping (graceful, person-attributed as before).
2117
+ function dispatchAgentId(cfg) {
2118
+ return cfg.get("dispatch.agent", null) || null;
2119
+ }
2120
+
2121
+ // Mint a per-session agent-scoped token (dec-spor-session-identity-active-record):
2122
+ // carries the agent (spiffe sub), the person (RFC 8693 act.sub), and the session
2123
+ // id; audience-restricted, short TTL — the server is the CA. SELF-SERVE and
2124
+ // OWNERSHIP-gated, NOT admin-gated: POST /v1/agents/{id}/token authenticated with
2125
+ // the dispatcher's normal person token (SPOR_TOKEN); the server checks the caller
2126
+ // OWNS agent {id} (the owned-by edge) — so a normal teammate can mint a token for
2127
+ // their own machine's agent without being an admin. REMOTE only. Returns
2128
+ // { ok, token } on success, { absent:true } when the mint surface isn't deployed
2129
+ // yet (404 — the spor-server stream is building it; fail soft, dispatch without
2130
+ // agent-scoping), or { error } on any other failure incl. 403/owner-mismatch
2131
+ // (also fail soft — warn and dispatch person-scoped, never block).
2132
+ async function mintAgentToken(cfg, { agent, session }) {
2133
+ const r = await remote.post(cfg, `/v1/agents/${encodeURIComponent(agent)}/token`, { session }, { timeoutMs: 6000 });
2134
+ if (r.transport) return { error: r.error };
2135
+ // 404 = no route (surface not deployed) => absent, dispatch falls back cleanly.
2136
+ if (r.status === 404) return { absent: true };
2137
+ if (!r.ok) return { error: `HTTP ${r.status}${r.json && r.json.error && r.json.error.code ? ` (${r.json.error.code})` : ""}` };
2138
+ const token = r.json && (r.json.token || r.json.access_token);
2139
+ if (!token) return { absent: true };
2140
+ return { ok: true, token };
2141
+ }
2142
+
2143
+ // Write the 0600 --mcp-config JSON that gives the bg agent ONLY its own
2144
+ // agent-scoped Spor MCP (account connector excluded by --strict-mcp-config,
2145
+ // verified #1). Machine-local, gitignored-adjacent path under the user config
2146
+ // home's outbox; per-session filename so concurrent dispatches don't collide.
2147
+ // Returns the file path. The bg agent reads it on startup AFTER this process
2148
+ // exits (claude --bg detaches), so we cannot delete it eagerly — cleanup is a
2149
+ // best-effort sweep of stale files here, plus the documented short-TTL token
2150
+ // inside it (dec-spor-session-identity-active-record open item).
2151
+ function writeDispatchMcpConfig(cfg, { token, session }) {
2152
+ const dir = path.join(cfg.userConfigHome(), "outbox", "dispatch");
2153
+ fs.mkdirSync(dir, { recursive: true });
2154
+ sweepStaleMcpConfigs(dir);
2155
+ const file = path.join(dir, `mcp-${session}.json`);
2156
+ const conf = {
2157
+ mcpServers: {
2158
+ spor: {
2159
+ type: "http",
2160
+ url: `${remote.base(cfg)}/mcp`,
2161
+ headers: { Authorization: `Bearer ${token}` },
2162
+ },
2163
+ },
2164
+ };
2165
+ // 0600: the file holds a live bearer token. Create with O_EXCL (wx) so a
2166
+ // pre-placed file or symlink at this path is REFUSED rather than written
2167
+ // through, and the file is 0600 from creation (no widen-then-narrow window).
2168
+ // The session-uuid filename makes a real collision a non-issue; a stale
2169
+ // leftover was swept above.
2170
+ const fd = fs.openSync(file, "wx", 0o600);
2171
+ try {
2172
+ fs.writeSync(fd, JSON.stringify(conf, null, 2) + "\n");
2173
+ } finally {
2174
+ fs.closeSync(fd);
2175
+ }
2176
+ return file;
2177
+ }
2178
+
2179
+ // Best-effort cleanup: remove dispatch mcp-config files older than a day. The
2180
+ // tokens inside are short-TTL, but the files linger because claude --bg reads
2181
+ // them after we exit; sweep on the next dispatch so they don't accumulate.
2182
+ function sweepStaleMcpConfigs(dir) {
2183
+ try {
2184
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
2185
+ for (const f of fs.readdirSync(dir)) {
2186
+ if (!/^mcp-.*\.json$/.test(f)) continue;
2187
+ const p = path.join(dir, f);
2188
+ try {
2189
+ if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p);
2190
+ } catch {
2191
+ /* racing another dispatch — ignore */
2192
+ }
2193
+ }
2194
+ } catch {
2195
+ /* dir vanished or unreadable — nothing to sweep */
2196
+ }
2197
+ }
2198
+
1890
2199
  async function cmdDispatch(cfg, { values, positionals: pos }) {
1891
2200
  const dryRun = !!(values.print || values["dry-run"]);
1892
2201
  const full = !!values.full;
@@ -2020,13 +2329,26 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2020
2329
  // exit / unparseable output => empty => no guard (fail-open); --force overrides.
2021
2330
  const inFlight = nodeId && !backfill ? dispatchedAgents().get(name) || [] : [];
2022
2331
 
2332
+ // Session identity (dec-spor-session-identity-active-record). Force the
2333
+ // session id up front — `--session-id <uuid>` makes the dispatched run's
2334
+ // session deterministic, so the agent-scoped token below can bind it AND the
2335
+ // claim can be session-bound at dispatch (the bg agent's post-tool heartbeat
2336
+ // renews under this same id). SPOR_SESSION_ID pins it for tests/reproducibility.
2337
+ const session = process.env.SPOR_SESSION_ID || crypto.randomUUID();
2338
+ // This machine's agent node — the WHO a dispatched session runs as. Only
2339
+ // meaningful remotely (the server is the CA that mints the agent token); a
2340
+ // local-mode dispatch or an unconfigured machine simply runs person-scoped.
2341
+ const identityAgent = cfg.mode() === "remote" ? dispatchAgentId(cfg) : null;
2342
+
2023
2343
  const claudeBin = claudeCmd();
2024
2344
  const claudeArgs = ["--bg"];
2025
2345
  if (name) claudeArgs.push("--name", name);
2026
2346
  if (model) claudeArgs.push("--model", model);
2027
2347
  if (permMode) claudeArgs.push("--permission-mode", permMode);
2028
2348
  if (agent) claudeArgs.push("--agent", agent);
2029
- claudeArgs.push(prompt);
2349
+ // Force the session id on every dispatch (local or remote). It is the run leaf
2350
+ // of the agent's identity and what the session-bound claim + heartbeat key on.
2351
+ claudeArgs.push("--session-id", session);
2030
2352
 
2031
2353
  if (dryRun) {
2032
2354
  out(`dir: ${res.dir} (slug: ${res.slug}, via ${res.source})`);
@@ -2038,6 +2360,17 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2038
2360
  out(`onboard: ${steps.join("; ")}`);
2039
2361
  }
2040
2362
  out(`brief: ${brief ? `${brief.length} bytes` : "(none — graph had nothing relevant, or --no-brief/--backfill)"}`);
2363
+ out(`session: ${session}`);
2364
+ // Identity preview: what the real dispatch would do for agent-scoping. The
2365
+ // token mint + 0600 mcp-config are SIDE EFFECTS, so --print only describes
2366
+ // them (it writes nothing and makes no network call here). Local mode and an
2367
+ // unconfigured machine read "person-scoped" — byte-stable but for the new
2368
+ // session line, which is additive and always present now.
2369
+ if (identityAgent) {
2370
+ out(`agent: ${identityAgent} (would mint a per-session agent-scoped token + write a 0600 --mcp-config, then add --strict-mcp-config)`);
2371
+ } else if (cfg.mode() === "remote") {
2372
+ out(`agent: (none configured — set dispatch.agent=agent-<machine> to attribute as agent-on-behalf-of; dispatching person-scoped)`);
2373
+ }
2041
2374
  // Same-machine guard preview (node mode, any mode): a real dispatch would
2042
2375
  // refuse if an agent with this name is already in flight here. Shown only on a
2043
2376
  // hit, so a clean node --print stays byte-identical to before.
@@ -2050,10 +2383,10 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2050
2383
  // Auto-claim preview (remote node dispatch only — local mode has no lease, so
2051
2384
  // nothing is announced there and local --print stays byte-identical).
2052
2385
  if (nodeId && !backfill && cfg.mode() === "remote") {
2053
- out(`claim: ${noClaim ? "(--no-claim — lease not established)" : `would establish a lease on ${nodeId} at launch`}`);
2386
+ out(`claim: ${noClaim ? "(--no-claim — lease not established)" : `would establish a session-bound lease on ${nodeId} at launch`}`);
2054
2387
  }
2055
2388
  if (template != null) out(`template: ${path.resolve(templateOpt)}`);
2056
- out(`run: ${claudeBin} ${claudeArgs.slice(0, -1).map(shellQuote).join(" ")} <prompt>`);
2389
+ out(`run: ${claudeBin} ${claudeArgs.map(shellQuote).join(" ")} <prompt>`);
2057
2390
  out(`\n--- prompt ---\n${prompt}`);
2058
2391
  return 0;
2059
2392
  }
@@ -2082,23 +2415,49 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2082
2415
  err("claude CLI not on PATH — install Claude Code, then re-run (or 'spor dispatch … --print' to see the prompt).");
2083
2416
  return 1;
2084
2417
  }
2418
+
2419
+ // Agent-scoped identity injection (dec-spor-session-identity-active-record,
2420
+ // the VERIFIED mechanism): mint a per-session agent-scoped token, write it into
2421
+ // a 0600 --mcp-config that exposes ONLY the agent's own Spor MCP, and add
2422
+ // --strict-mcp-config so the account connector is excluded by construction. The
2423
+ // server then stamps authored_by_agent + session from that token. FAIL SOFT at
2424
+ // every step — a server without the mint surface, or a transient error, falls
2425
+ // back to the prior person-scoped dispatch with a clear line. Remote + a
2426
+ // configured agent only; local/unconfigured dispatch is byte-identical but for
2427
+ // the always-present --session-id.
2428
+ if (identityAgent) {
2429
+ const mint = await mintAgentToken(cfg, { agent: identityAgent, session });
2430
+ if (mint.ok) {
2431
+ const mcpFile = writeDispatchMcpConfig(cfg, { token: mint.token, session });
2432
+ claudeArgs.push("--mcp-config", mcpFile, "--strict-mcp-config");
2433
+ out(`agent: ${identityAgent} (session ${session} — writes attributed agent-on-behalf-of-you)`);
2434
+ } else if (mint.absent) {
2435
+ err(`warning: this server can't mint agent-scoped session tokens yet — dispatching person-scoped (session ${session}).`);
2436
+ } else {
2437
+ err(`warning: could not mint an agent token (${mint.error}) — dispatching person-scoped (session ${session}).`);
2438
+ }
2439
+ }
2440
+
2085
2441
  // Establish the claim/lease BEFORE launching (task-spor-dispatch-auto-claim):
2086
2442
  // a node already claimed by someone else is caught here, so we never launch a
2087
2443
  // duplicate agent onto contested work, and the lease is live the moment the
2088
2444
  // agent starts (its post-tool writes then renew it — and seeing its own held
2089
2445
  // claim, it skips the redundant claim-nudge). Remote node-mode only; --no-claim
2090
- // opts out (dispatch with no lease, the prior behavior).
2446
+ // opts out (dispatch with no lease, the prior behavior). Now SESSION-BOUND
2447
+ // (dec-spor-session-identity-active-record): dispatch forces the session id, so
2448
+ // the lease binds it and the bg agent's same-session heartbeat renews it.
2091
2449
  if (nodeId && !backfill && !noClaim && cfg.mode() === "remote") {
2092
- const c = await claimDispatch(cfg, nodeId);
2450
+ const c = await claimDispatch(cfg, nodeId, session);
2093
2451
  if (c.conflict) {
2094
2452
  err(`${nodeId} is already claimed — ${c.message}`);
2095
2453
  err(` not dispatching a duplicate. Re-run with --no-claim to dispatch anyway (no lease),`);
2096
2454
  err(` or pick another task with 'spor next'.`);
2097
2455
  return 1;
2098
2456
  }
2099
- if (c.ok) out(`claimed ${nodeId} (lease established; the agent's writes will renew it)`);
2457
+ if (c.ok) out(`claimed ${nodeId} (session-bound lease established; the agent's writes will renew it)`);
2100
2458
  else err(`warning: could not establish a lease on ${nodeId}: ${c.error} — dispatching without a claim`);
2101
2459
  }
2460
+ claudeArgs.push(prompt);
2102
2461
  const r = spawnSync(claudeBin, claudeArgs, { cwd: res.dir, stdio: "inherit" });
2103
2462
  if (r.error) {
2104
2463
  err(`could not launch ${claudeBin}: ${r.error.message}`);
@@ -2282,6 +2641,25 @@ const COMMANDS = {
2282
2641
  examples: ["spor token list", "spor token revoke a1b2c3"],
2283
2642
  run: (cfg, args) => cmdToken(cfg, args),
2284
2643
  },
2644
+ agent: {
2645
+ group: "Team admin (remote, admin token)", parse: "raw", args: "create <label> [--owner <id>] [--pubkey <fp>] | list",
2646
+ summary: "person-owned automation principals (dispatch identity)",
2647
+ help:
2648
+ "Create and list agents — first-class `type: agent` nodes owned by a person\n" +
2649
+ "(dec-spor-agent-identity-nodes). A dispatched session runs AS its agent, so its\n" +
2650
+ "writes read \"agent on behalf of person\" rather than person-direct. One durable\n" +
2651
+ "agent per machine/install, reused across dispatches.\n\n" +
2652
+ " spor agent create <label> create the agent + its owned-by edge to a person\n" +
2653
+ " --owner <person-id> owner (remote: defaults to your person; local:\n" +
2654
+ " defaults to the sole person node, else required)\n" +
2655
+ " --pubkey <fingerprint> record a public-key fingerprint (forward-compat,\n" +
2656
+ " unenforced — may be omitted)\n" +
2657
+ " spor agent list list agents and their owners\n\n" +
2658
+ "Remote mode creates through POST /v1/admin/agents (admin-gated, the server mints\n" +
2659
+ "the spiffe). Local mode writes the node + owned-by edge to the graph home.",
2660
+ examples: ["spor agent create anthony-laptop", "spor agent create ci-runner --owner person-anthony", "spor agent list"],
2661
+ run: (cfg, args) => cmdAgent(cfg, args),
2662
+ },
2285
2663
 
2286
2664
  // --- Graph ---
2287
2665
  add: {
package/lib/config.js CHANGED
@@ -86,6 +86,7 @@ const ENV_MAP = [
86
86
  ["QUEUE_FRONT", "queue.front.enabled"], // SPOR_QUEUE_FRONT=0 disables local git-derived front
87
87
  ["QUEUE_FRONT_DAYS", "queue.front.days"], // rolling front window (days)
88
88
  ["QUEUE_PROJECT", "queue.project"], // default --project scope for `spor next` (both modes); explicit --project wins
89
+ ["DISPATCH_AGENT", "dispatch.agent"], // this machine's default agent for `spor dispatch` (per-machine, like dispatch.repos)
89
90
  ];
90
91
 
91
92
  function isPlainObject(v) {
package/lib/graph.js CHANGED
@@ -86,6 +86,8 @@ module.exports = {
86
86
  renderSkeleton,
87
87
  parseFrontmatter: kernel.parseFrontmatter,
88
88
  rankAgainst: kernel.rankAgainst,
89
+ // agent-on-behalf-of authorship read-out (dec-spor-agent-identity-nodes)
90
+ authorshipLine: kernel.authorshipLine,
89
91
  // project identity (task-cc-project-identity-nodes): slug-alias resolution
90
92
  resolveProject: kernel.resolveProject,
91
93
  // grouping membership (task-cc-grouping-brief-digest-reads): repo<->grouping
@@ -423,6 +423,26 @@ function structuralWalk(graph, seeds) { // seeds: {id: initialScore}
423
423
  return best;
424
424
  }
425
425
 
426
+ // Authorship read-out (dec-spor-agent-identity-nodes). A node written by a
427
+ // dispatched agent carries the additive `authored_by_agent: agent-<id>` stamp
428
+ // (server-applied from the token, never the payload) alongside its unchanged
429
+ // person `author:`. When present, the node reads "agent <label> on behalf of
430
+ // <person>" — the agent is the WHO that did the work, the person remains the
431
+ // accountable owner. ADDITIVE and backward-readable: a node WITHOUT the stamp
432
+ // (every pre-feature node, and every person-direct write) returns the plain
433
+ // `author` string exactly as before, so existing output is byte-identical. The
434
+ // agent's display label is its node title when the agent node is in the graph,
435
+ // else the bare id; an unresolvable stamp still reads as agent-on-behalf-of so
436
+ // the provenance survives a missing agent node.
437
+ function authorshipLine(node, nodes) {
438
+ const author = (typeof node.author === "string" && node.author.trim()) || "";
439
+ const byAgent = typeof node.authored_by_agent === "string" && node.authored_by_agent.trim();
440
+ if (!byAgent) return author; // person-direct — unchanged
441
+ const agentNode = nodes && nodes[byAgent];
442
+ const label = (agentNode && typeof agentNode.title === "string" && agentNode.title.trim()) || byAgent;
443
+ return author ? `agent ${label} on behalf of ${author}` : `agent ${label}`;
444
+ }
445
+
426
446
  // ---------- compile ----------
427
447
  //
428
448
  // opts: { rootId?, query?, digest?, minSim?, seedSchemas? }
@@ -746,7 +766,12 @@ function compile(graph, opts = {}) {
746
766
  // node's `author:` frontmatter); unattributed norms say so, which is itself a
747
767
  // signal to distrust.
748
768
  function renderNorm(n, full) {
749
- const author = (typeof n.author === "string" && n.author.trim()) || "unattributed — treat with extra suspicion";
769
+ // Agent-on-behalf-of read-out (dec-spor-agent-identity-nodes): when the norm
770
+ // carries an `authored_by_agent` stamp it reads "agent <label> on behalf of
771
+ // <person>"; without it, the plain person author exactly as before (so a
772
+ // norm with no stamp — every existing one — is byte-identical). An entirely
773
+ // unattributed norm keeps its distrust marker.
774
+ const author = authorshipLine(n, nodes) || "unattributed — treat with extra suspicion";
750
775
  const head = `### ${n.id} — ${n.title} (norm, ${n.date}${statusTag(n)}${crossTag(n)})\n*authored by: ${author}*\n`;
751
776
  const body = full ? n.body : n.summary;
752
777
  // Blockquote every line so the norm reads as quoted data, not prose
@@ -1050,6 +1075,7 @@ module.exports = {
1050
1075
  validateNode,
1051
1076
  validateGraphFiles,
1052
1077
  rankAgainst,
1078
+ authorshipLine,
1053
1079
  registry,
1054
1080
  DIGEST_CAP,
1055
1081
  DEFAULT_MIN_SIM,
@@ -0,0 +1,67 @@
1
+ ---
2
+ id: schema-agent
3
+ type: schema
4
+ kind: node-schema
5
+ schema_version: 2026.06.16.1
6
+ title: Seed schema for agent nodes
7
+ summary: Node schema for the agent type — a person-owned automation principal (a dispatched Claude session's durable identity), owned by a person via an `owned-by` edge and carrying a forward-compat `spiffe:`/`pubkey:` shape. One persistent node per machine/install, reused across dispatches; its writes are attributed "agent on behalf of person", not as the person directly. Seed-pack default; a graph-resident schema node for this type overrides it.
8
+ date: 2026-06-16
9
+ ---
10
+
11
+ Seed schema for the `agent` node type (ontology in GRAPH.md), shipped with the
12
+ plugin as a registry default (QUEUE.md §2). A `type: schema` node in the graph
13
+ with `kind: node-schema` and the same `node_type` overrides this entry.
14
+
15
+ An agent node makes a person's automation principal first-class instead of an
16
+ anonymous flag on a write (dec-spor-agent-identity-nodes). It generalizes the
17
+ workflow-run principal the graph already carries: an interactively dispatched
18
+ `claude --bg` session is just another principal kind owned by a person, so its
19
+ created nodes can read "agent on behalf of person" instead of person-direct.
20
+
21
+ **Grain: persistent + lightweight runs.** One durable agent node per
22
+ machine/install, created once (e.g. `agent-anthony-laptop`) by `spor agent
23
+ create`, and REUSED across dispatches — NOT one node per session. The Claude
24
+ Code `session_id` of each dispatch is the ephemeral which-run; it rides as an
25
+ additive `session:` stamp on created nodes, not its own node (promote to a
26
+ `run-<id>` node only if run metadata later needs a home).
27
+
28
+ **Ownership is an edge, not a field.** An agent's owner is recorded with an
29
+ `owned-by` edge → its `person-<id>` (schema-edge-owned-by), so the binding is a
30
+ first-class, traversable graph fact rather than a frontmatter scalar — the same
31
+ shape question routing and `$viewer` already key on for person edges. The
32
+ agent/person binding is authorization-load-bearing, so like all attribution
33
+ (dec-cc-attribution-from-token) it is asserted only by an authenticated
34
+ identity, never from a caller's payload.
35
+
36
+ Instances carry, beyond the standard fields (`id`, `type`, `title`, `summary`,
37
+ `date`), three identity registers — all flat scalars the regex frontmatter
38
+ parser already supports:
39
+
40
+ - `spiffe:` — a SPIFFE-shaped URI encoding the agent→person binding in its
41
+ path: `spiffe://spor.<org>/person/<person-id>/agent/<label>`. The dispatch
42
+ run extends it to the session leaf the workflow side already uses
43
+ (`…/agent/<label>/session/<uuid>`). Adopted as forward-compat SHAPE
44
+ (dec-cc-spiffe-forward-compat) — recorded, not yet runtime-verified.
45
+ - `pubkey:` — the agent's public-key fingerprint string. MAY BE EMPTY in this
46
+ cut; recorded for forward-compat (server-token JWKS / local signed-commit
47
+ web-of-trust) but UNENFORCED — no signature verification ships yet.
48
+ - `status: active` — an agent is live by default. Any other status (or none)
49
+ reads as before; declarative data only, not gated by a `transitions()` enum,
50
+ so it stays backward-readable.
51
+
52
+ `capturable: false`: agent identity is created deliberately (`spor agent
53
+ create`, or the admin endpoint), never drafted from a capture or a distilled
54
+ transcript — mirroring `person`, `repo`, and `workflow-run`. Graphs without
55
+ agent nodes behave exactly as before; their nodes simply lack the
56
+ `authored_by_agent`/`session` attribution stamps and read as person-direct.
57
+
58
+ ```json
59
+ {
60
+ "node_type": "agent",
61
+ "description": "a person-owned automation principal — a dispatched session's durable identity, owned by a person via an owned-by edge; its writes are attributed agent-on-behalf-of-person",
62
+ "prefix": [
63
+ "agent-"
64
+ ],
65
+ "capturable": false
66
+ }
67
+ ```
@@ -0,0 +1,37 @@
1
+ ---
2
+ id: schema-edge-owned-by
3
+ type: schema
4
+ kind: edge-schema
5
+ schema_version: 2026.06.16.1
6
+ title: Seed schema for owned-by edges
7
+ summary: Edge schema for the owned-by type — an agent is owned by a person; the identity-binding key for agent-on-behalf-of-person attribution. Structural membership like grouped-under, not a work dependency. Seed-pack default; a graph-resident schema node for this edge type overrides it.
8
+ date: 2026-06-16
9
+ ---
10
+
11
+ Seed schema for the `owned-by` edge type (agent → person), shipped with the
12
+ plugin as a registry default (QUEUE.md §2). Written from a `type: agent` node
13
+ (schema-agent) to its owning `person-` node by `spor agent create` / the admin
14
+ endpoint, recording the person-owns-agent binding as a first-class graph fact
15
+ (dec-spor-agent-identity-nodes).
16
+
17
+ A low structural weight, mirroring `grouped-under` (0.3): like a repo's home
18
+ grouping, an agent's owner is durable membership, not work flow — the owner
19
+ rarely needs to be pulled into a work neighborhood, and a low weight keeps agent
20
+ identity from polluting task briefings. It sits just under the person-graph
21
+ identity edges (`stewards` 0.4, `assigned` 0.5), which DO carry work signal. Its
22
+ inverse `owns` is how the binding reads from the person's side (person owns
23
+ agent), flipped onto the person on write.
24
+
25
+ `capturable: false`: the binding is created deliberately alongside the agent
26
+ node (schema-agent), never drafted from a capture — mirroring the identity-node
27
+ types it joins.
28
+
29
+ ```json
30
+ {
31
+ "edge_type": "owned-by",
32
+ "description": "this agent is owned by the target person — the agent-on-behalf-of-person identity binding",
33
+ "weight": 0.3,
34
+ "inverse_label": "owns",
35
+ "capturable": false
36
+ }
37
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Spor — a shared memory substrate for teams and agents. Decisions, their reasons, and the traces they leave. Knowledge-graph context compiler: session-start briefings, per-prompt digests, capture at discovery, end-of-session distillation, decision queue.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Anthony Allen",
@@ -127,10 +127,20 @@ async function promptContext(input) {
127
127
  const rlogFile = path.join(graph, "journal", "remote.log");
128
128
  const rlog = u.makeLogger(rlogFile, "prompt-context: ");
129
129
 
130
+ // Send the session project so the SERVER-side compile applies the same
131
+ // project scoping the local digest already gets — the same-project
132
+ // relevance boost, the grouping union, and the norm `applies_to_*`
133
+ // ride-along (issue-spor-remote-digest-project-blind). The remote digest
134
+ // was project-blind: it posted only `query`, so every compile() session-
135
+ // project feature silently no-opped in remote mode. `slug` is the same
136
+ // `projectSlug(cwd)` already fed to the local merge below. Absent a slug
137
+ // the body is byte-identical to the prior project-blind POST, and an older
138
+ // server simply ignores the field (it stays the default), so this is safe
139
+ // either way.
130
140
  const resp = await u.curl(`${u.serverBase()}/v1/digest`, {
131
141
  method: "POST",
132
142
  headers: { ...u.bearer(), "Content-Type": "application/json" },
133
- body: JSON.stringify({ query: prompt }),
143
+ body: JSON.stringify(slug ? { query: prompt, project: slug } : { query: prompt }),
134
144
  timeoutMs: 4000,
135
145
  });
136
146
 
@@ -24,15 +24,28 @@ Steps:
24
24
  **Remote mode (team graph) — when `SPOR_SERVER` is set:** the compile
25
25
  runs on the server. Use its REST twins (API.md §3) instead of the local
26
26
  compile.js. (Env vars here are the `SPOR_*` family; the legacy
27
- `SUBSTRATE_*` names are still read.)
27
+ `SUBSTRATE_*` names are still read.) First resolve THIS repo's project slug
28
+ the same way a session does and send it as `"project"` so the server scopes
29
+ the compile to your repo — the same-project relevance boost, the grouping
30
+ union, and the `always_on` norm `applies_to_*` ride-along — instead of
31
+ running *project-blind* (issue-spor-remote-digest-project-blind). Resolve the
32
+ plugin root from the session-start cache, then the slug:
33
+ ```bash
34
+ SPOR_ROOT="$(cat "${SPOR_HOME:-$HOME/.spor}/cache/plugin-root" 2>/dev/null \
35
+ || cat "$HOME/.substrate/cache/plugin-root" 2>/dev/null)"
36
+ SPOR_ROOT="${SPOR_ROOT:-$CLAUDE_PLUGIN_ROOT}"
37
+ SLUG="$(node -e 'process.stdout.write(require("'"$SPOR_ROOT"'/scripts/engines/util.js").projectSlug(process.cwd()))' 2>/dev/null)"
38
+ ```
28
39
  - free-text query: `POST ${SPOR_SERVER%/}/v1/digest` with
29
- `{"query":"<text>"}` (`Authorization: Bearer $SPOR_TOKEN`); the `text`
30
- field is the compiled neighborhood. A `{"found":false}` means the team
31
- graph has nothing relevant — say so and stop.
40
+ `{"query":"<text>","project":"<slug>"}` (`Authorization: Bearer
41
+ $SPOR_TOKEN`); the `text` field is the compiled neighborhood. A
42
+ `{"found":false}` means the team graph has nothing relevant — say so and
43
+ stop. (Omit `project` only if the slug couldn't be resolved.)
32
44
  - node id: `GET ${SPOR_SERVER%/}/v1/nodes/<id>` for the raw node, and
33
- `POST /v1/digest` with the node's title/summary as the query for its
34
- neighborhood. In Cowork, call the `query_graph` MCP tool (with `root_id` for
35
- a node id, or `query` for free text) — there is no compile.js there.
45
+ `POST /v1/digest` with the node's title/summary as the query (plus the same
46
+ `"project":"<slug>"`) for its neighborhood. In Cowork, call the
47
+ `query_graph` MCP tool (with `root_id` for a node id, or `query` for free
48
+ text) — there is no compile.js there.
36
49
 
37
50
  **Local mode (personal graph) — `SPOR_SERVER` unset:** first resolve the
38
51
  plugin root — `${CLAUDE_PLUGIN_ROOT}` is empty in the Bash tool, so read
@@ -85,6 +85,10 @@ spor add "<2-3 sentences>" # capture a node (typed file locally; /v1/capture
85
85
 
86
86
  # remote (team server) only
87
87
  spor lens [<id>] # list saved views, or render one
88
+ spor agent create <label> # create one of your agents — a person-owned principal (`spor agent list`)
89
+ spor dispatch <id>|"<task>" # run work as a background agent; in team mode its graph writes are
90
+ # attributed "agent on behalf of you" (the machine's default agent =
91
+ # dispatch.agent / SPOR_DISPATCH_AGENT). See API.md §3-§4.
88
92
 
89
93
  # dual-mode (local passthrough / remote dispatch to the server)
90
94
  spor compile --query "<text>" # search → compiled neighborhood (--digest for compact)
@@ -23,6 +23,7 @@ the decision queue (QUEUE.md §4).
23
23
  | correction | `corr-` | a standing fix to a briefing (pin/exclude/guidance) | `traversable: false`; applied at every future compile of its target |
24
24
  | question | `question-` | a routed ask the graph couldn't answer | queueable; status `open`/`answered`; joins the queue until answered |
25
25
  | person | `person-` | an org member | anchor for `$viewer` binding and question routing |
26
+ | agent | `agent-` | a person-owned automation principal | a dispatched session's durable identity; owned by a person via `owned-by`; `capturable: false`; carries forward-compat `spiffe:`/`pubkey:` |
26
27
  | capture-pending | `cap-` | raw captured text that fit no schema | born status-less; closes only as `merged` or `rejected` |
27
28
  | finding | `find-` | a gardener observation (stale anchor, cold work) | filed as a queue item |
28
29
  | repo | `repo-` | a durable git-repo identity | carries `slugs:` aliases + `fingerprints:`; heals renames at read time; optional `tags:` are the match key for a norm's `applies_to_tags` |
@@ -55,6 +56,7 @@ are same-direction synonyms renamed at write time.
55
56
  | mentions | 0.5 | weakest association | — |
56
57
  | stewards | 0.4 | this person stewards the target area/spec/norm | question-routing key |
57
58
  | grouped-under | 0.3 | this repo's home project grouping (structural) | inverse `groups` |
59
+ | owned-by | 0.3 | this agent is owned by that person (structural identity) | inverse `owns` |
58
60
  | routed-to | 0.3 | this question is routed to that person | — |
59
61
  | compiled-for | — | briefing → the task/query it was compiled for | provenance only |
60
62
  | shaped-by | — | briefing → the corrections that shaped it | provenance only |
@@ -62,7 +64,7 @@ are same-direction synonyms renamed at write time.
62
64
  **Ride-along flags** (set in a schema's JSON payload):
63
65
  - `always_on: true` (norm) — injected into every project-relevant compile.
64
66
  - `traversable: false` (briefing, correction) — excluded from lineage walks.
65
- - `capturable: false` (briefing, workflow-run) — never produced by capture.
67
+ - `capturable: false` (briefing, workflow-run, agent) — never produced by capture.
66
68
 
67
69
  An `always_on` norm rides along project-wide by default, but that scope is the
68
70
  whole home-project **grouping** — so under a project that spans heterogeneous
@@ -107,7 +109,9 @@ a load-bearing reason exists ("because the release is Friday"), record it here.
107
109
  One fact per node; if you're writing "and also…", split it.
108
110
  ```
109
111
 
110
- Server-stamped fields you don't set by hand: `author`, `authored_via`. Other
112
+ Server-stamped fields you don't set by hand: `author`, `authored_via` (plus `authored_by_agent` +
113
+ `session`, with `authored_via: dispatch`, on a write made under an agent-scoped
114
+ token — the node reads "agent on behalf of person"). Other
111
115
  type-specific fields exist (`wake:` dormancy date; `commits:` linked git shas;
112
116
  `pin:`/`exclude:` on corrections; `slugs:`/`fingerprints:`/`tags:` on repo nodes;
113
117
  `applies_to_tags:`/`applies_to_repos:`/`applies_to_projects:` ride-along