@sporhq/spor 0.6.0 → 0.7.1

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.1",
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,21 @@ 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 (set with
380
+ `spor agent use <agent-id>`, or `SPOR_DISPATCH_AGENT`), which `spor dispatch
381
+ --as <agent-id>` overrides for a single run. (Not to be confused with `spor
382
+ dispatch --agent`, the unrelated `claude --agent` harness passthrough.)
360
383
  - **OAuth 2.1 for MCP connectors** (Cowork/claude.ai, which cannot carry a
361
384
  static bearer token): protected-resource metadata discovery (RFC 9728,
362
385
  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,254 @@ 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
+ if (sub === "use") {
981
+ return cmdAgentUse(cfg, { id: args[1] });
982
+ }
983
+ err("usage: spor agent create <label> [--owner person-x] [--pubkey <fp>] | spor agent list | spor agent use <agent-id>");
984
+ return 1;
985
+ }
986
+
987
+ // `spor agent use <agent-id>` — make this agent the machine's default dispatch
988
+ // identity by writing `dispatch.agent` to the USER config.json (the same
989
+ // machine-local, never-committed file as the repo map; per-machine, like
990
+ // dispatch.repos). This is the real setter the create/list hints point to;
991
+ // before it, dispatch.agent was settable only via env or by hand-editing the
992
+ // config. `spor agent use --clear` (or an empty id) drops back to person-scoped
993
+ // dispatch. Not a graph write — purely local config, so it works in both modes.
994
+ function cmdAgentUse(cfg, { id }) {
995
+ const clear = id === "--clear" || id === "none" || id === "";
996
+ if (!id) {
997
+ err("usage: spor agent use <agent-id> (or: spor agent use --clear)");
998
+ return 1;
999
+ }
1000
+ if (!clear && !/^[a-z0-9][a-z0-9-]*$/.test(id)) {
1001
+ err(`invalid agent id '${id}' — must match ^[a-z0-9][a-z0-9-]*$ (e.g. agent-your-machine)`);
1002
+ return 1;
1003
+ }
1004
+ const home = cfg.userConfigHome();
1005
+ const wrote = u.setDispatchAgent(home, clear ? null : id);
1006
+ if (clear) {
1007
+ out(wrote ? "cleared dispatch.agent — dispatches run person-scoped again" : "dispatch.agent was already unset");
1008
+ return 0;
1009
+ }
1010
+ if (wrote) {
1011
+ out(`dispatch.agent = ${id} (this machine now dispatches as ${id}; ${path.join(home, "config.json")})`);
1012
+ } else {
1013
+ out(`dispatch.agent already = ${id} (no change)`);
1014
+ }
1015
+ out(" attribution is remote-only; override one dispatch with: spor dispatch --as <agent-id>");
1016
+ return 0;
1017
+ }
1018
+
1019
+ async function cmdAgentCreateRemote(cfg, { label, owner, pubkey }) {
1020
+ const body = { label };
1021
+ if (owner) body.owner = owner;
1022
+ if (pubkey) body.pubkey = pubkey;
1023
+ const r = await remote.post(cfg, "/v1/admin/agents", body);
1024
+ if (r.transport) {
1025
+ err(`offline — could not reach server (${r.error})`);
1026
+ return 1;
1027
+ }
1028
+ if (notAdminHint(r)) return 1;
1029
+ if (r.status === 404) {
1030
+ // The endpoint is part of the agent-identity rollout (the spor-server
1031
+ // stream); an older server doesn't have it yet. Fail soft, don't crash.
1032
+ err("this server has no agent-creation endpoint yet (POST /v1/admin/agents).");
1033
+ err(" upgrade the Spor server, or create the agent in local mode against a checkout.");
1034
+ return 1;
1035
+ }
1036
+ if (r.status === 409) {
1037
+ err(`agent already exists: ${(r.json && r.json.error && r.json.error.message) || "duplicate id"}`);
1038
+ return 1;
1039
+ }
1040
+ if (!r.ok) {
1041
+ err(`agent create failed (${r.status}): ${(r.json && r.json.error && r.json.error.message) || r.text}`);
1042
+ return 1;
1043
+ }
1044
+ const j = r.json || {};
1045
+ out(`created agent ${j.id || `agent-${kebab(label)}`}${j.owner ? ` owned by ${j.owner}` : ""}`);
1046
+ if (j.spiffe) out(` spiffe: ${j.spiffe}`);
1047
+ out(` make it this machine's default: spor agent use ${j.id || `agent-${kebab(label)}`}`);
1048
+ out(` or dispatch as it once: spor dispatch --as ${j.id || `agent-${kebab(label)}`} …`);
1049
+ return 0;
1050
+ }
1051
+
1052
+ // Build the agent node + owned-by edge locally. Owner defaults to a single
1053
+ // person node in the graph when unambiguous (the solo-local common case),
1054
+ // else it must be named — the binding is identity-load-bearing, never guessed.
1055
+ async function cmdAgentCreateLocal(cfg, { label, owner, pubkey }) {
1056
+ const graphLib = require(path.join(ROOT, "lib", "graph.js"));
1057
+ const nodesDir = cfg.nodesDir();
1058
+ if (!fs.existsSync(nodesDir)) {
1059
+ err(`no graph at ${nodesDir} — run 'spor init' first`);
1060
+ return 1;
1061
+ }
1062
+ let g;
1063
+ try {
1064
+ g = graphLib.loadGraph(nodesDir);
1065
+ } catch (e) {
1066
+ err(`could not load graph: ${e.message}`);
1067
+ return 1;
1068
+ }
1069
+ let ownerId = owner;
1070
+ if (!ownerId) {
1071
+ const people = Object.values(g.nodes || {}).filter((n) => n.type === "person");
1072
+ if (people.length === 1) {
1073
+ ownerId = people[0].id;
1074
+ } else if (people.length === 0) {
1075
+ err("no person node in the graph to own this agent — pass --owner person-x");
1076
+ err(" (an agent's owner is recorded as an owned-by edge to a person node).");
1077
+ return 1;
1078
+ } else {
1079
+ err(`several person nodes — name the owner with --owner (one of: ${people.map((p) => p.id).slice(0, 6).join(", ")}${people.length > 6 ? ", …" : ""})`);
1080
+ return 1;
1081
+ }
1082
+ } else if (!(g.nodes && g.nodes[ownerId])) {
1083
+ err(`no such person node: ${ownerId}`);
1084
+ return 1;
1085
+ }
1086
+
1087
+ const prefix = (g.registry && g.registry.prefixesFor("agent") || ["agent-"])[0] || "agent-";
1088
+ const id = `${prefix}${kebab(label)}`;
1089
+ if (fs.existsSync(path.join(nodesDir, `${id}.md`))) {
1090
+ err(`agent already exists: ${id}`);
1091
+ return 1;
1092
+ }
1093
+ // Forward-compat spiffe shape (dec-cc-spiffe-forward-compat): recorded, not
1094
+ // verified. <org> from config (default "local") so a solo graph is sensible.
1095
+ const org = cfg.get("org", null) || "local";
1096
+ const personLabel = ownerId.replace(/^person-/, "") || ownerId;
1097
+ const spiffe = `spiffe://spor.${org}/person/${personLabel}/agent/${kebab(label)}`;
1098
+ const md =
1099
+ `---\nid: ${id}\ntype: agent\ntitle: ${label.replace(/\n/g, " ")}\n` +
1100
+ `summary: Automation principal ${label}, owned by ${ownerId} — its dispatched-session writes read "agent on behalf of person".\n` +
1101
+ `spiffe: ${spiffe}\npubkey: ${pubkey.replace(/\n/g, " ")}\nstatus: active\ndate: ${today()}\n` +
1102
+ `edges:\n - {type: owned-by, to: ${ownerId}}\n---\n\n` +
1103
+ `Person-owned automation principal (dec-spor-agent-identity-nodes). Created by \`spor agent create\`; reused across dispatches as this machine's durable identity.\n`;
1104
+ let node;
1105
+ try {
1106
+ node = graphLib.parseFrontmatter(md, `${id}.md`);
1107
+ } catch (e) {
1108
+ err(`invalid node: ${e.message}`);
1109
+ return 1;
1110
+ }
1111
+ const v = graphLib.validateNode(g, node);
1112
+ if (!v.ok) {
1113
+ err(`invalid agent node:\n ${v.errors.join("\n ")}`);
1114
+ return 1;
1115
+ }
1116
+ fs.writeFileSync(path.join(nodesDir, `${id}.md`), md);
1117
+ out(`created agent ${id} owned by ${ownerId}`);
1118
+ out(` spiffe: ${spiffe}`);
1119
+ out(` make it this machine's default: spor agent use ${id}`);
1120
+ out(` (note: agent-on-behalf-of attribution applies in remote mode)`);
1121
+ return 0;
1122
+ }
1123
+
1124
+ // List agent nodes. Remote: GET /v1/agents (the caller's owned agents — the
1125
+ // dedicated route). If that surface isn't deployed (404), fall back to projecting
1126
+ // the /v1/changes audit trail and keeping the type:agent rows (newest change per
1127
+ // node first, so the first row per id is the live one). Local: scan the graph
1128
+ // home. Fail-soft on any read error.
1129
+ async function cmdAgentListRemote(cfg) {
1130
+ const a = await remote.get(cfg, "/v1/agents", { timeoutMs: 6000 });
1131
+ if (a.transport) {
1132
+ err(`offline — could not reach server (${a.error})`);
1133
+ return 1;
1134
+ }
1135
+ if (a.ok && a.json && Array.isArray(a.json.agents)) {
1136
+ const rows = a.json.agents.map((ag) => `${ag.id}\t${ag.owner ? `owned-by ${ag.owner}` : (ag.title || "")}\t${ag.status || "active"}`);
1137
+ if (!rows.length) {
1138
+ out("no agents yet — create one with 'spor agent create <label>'");
1139
+ return 0;
1140
+ }
1141
+ rows.forEach((l) => out(l));
1142
+ return 0;
1143
+ }
1144
+ // /v1/agents not deployed yet — degrade to the audit-trail projection, which
1145
+ // every remote client already has.
1146
+ if (a.status === 404) {
1147
+ const q = await remote.get(cfg, "/v1/changes?limit=500", { timeoutMs: 6000 });
1148
+ if (q.ok && q.json && Array.isArray(q.json.changes)) {
1149
+ const seen = new Set();
1150
+ const rows = [];
1151
+ for (const c of q.json.changes) {
1152
+ if (!c || c.type !== "agent" || c.change === "deleted") continue;
1153
+ if (seen.has(c.id)) continue; // first (newest) wins
1154
+ seen.add(c.id);
1155
+ rows.push(`${c.id}\t${c.title || ""}`);
1156
+ }
1157
+ if (!rows.length) {
1158
+ out("no agents yet — create one with 'spor agent create <label>'");
1159
+ return 0;
1160
+ }
1161
+ rows.forEach((l) => out(l));
1162
+ return 0;
1163
+ }
1164
+ }
1165
+ err("could not list agents from this server (no /v1/agents or /v1/changes route).");
1166
+ err(" list them in local mode against a checkout, or upgrade the server.");
1167
+ return 1;
1168
+ }
1169
+
1170
+ function cmdAgentListLocal(cfg) {
1171
+ const graphLib = require(path.join(ROOT, "lib", "graph.js"));
1172
+ const nodesDir = cfg.nodesDir();
1173
+ if (!fs.existsSync(nodesDir)) {
1174
+ err(`no graph at ${nodesDir} — run 'spor init' first`);
1175
+ return 1;
1176
+ }
1177
+ let g;
1178
+ try {
1179
+ g = graphLib.loadGraph(nodesDir);
1180
+ } catch (e) {
1181
+ err(`could not load graph: ${e.message}`);
1182
+ return 1;
1183
+ }
1184
+ const agents = Object.values(g.nodes || {}).filter((n) => n.type === "agent");
1185
+ if (!agents.length) {
1186
+ out("no agents yet — create one with 'spor agent create <label>'");
1187
+ return 0;
1188
+ }
1189
+ for (const a of agents.sort((x, y) => x.id.localeCompare(y.id))) {
1190
+ const ownedBy = (a.edges || []).find((e) => e.type === "owned-by");
1191
+ const status = a.status || "active";
1192
+ out(`${a.id}\t${ownedBy ? `owned-by ${ownedBy.to}` : "(no owner)"}\t${status}`);
1193
+ }
1194
+ return 0;
1195
+ }
1196
+
948
1197
  async function cmdToken(cfg, args) {
949
1198
  if (cfg.mode() !== "remote") {
950
1199
  err("token admin needs a team graph (remote mode).");
@@ -1421,7 +1670,10 @@ function detectHosts() {
1421
1670
  // already ensured the claude CLI exists and the marketplace is registered.
1422
1671
  function refreshClaudePlugin(cmd, cliScope, before) {
1423
1672
  spawnSync(cmd, ["plugin", "marketplace", "update", "spor"], { encoding: "utf8" });
1424
- const upd = spawnSync(cmd, ["plugin", "update", "spor", "--scope", cliScope], { stdio: "inherit" });
1673
+ // Claude Code resolves an installed plugin by its name@marketplace id (the
1674
+ // install side uses 'spor@spor'); the bare 'spor' is unresolvable and fails
1675
+ // with "Plugin 'spor' not found" (issue-spor-upgrade-wrong-plugin-marketplace-id).
1676
+ const upd = spawnSync(cmd, ["plugin", "update", "spor@spor", "--scope", cliScope], { stdio: "inherit" });
1425
1677
  if (upd.status !== 0) {
1426
1678
  err(`claude plugin update failed (exit ${upd.status == null ? "?" : upd.status})`);
1427
1679
  return 1;
@@ -1606,7 +1858,10 @@ function upgradeClaude(scope, dryRun) {
1606
1858
  const cliScope = scope === "repo" ? "project" : "user";
1607
1859
  const mpAdd = ["plugin", "marketplace", "add", ROOT];
1608
1860
  const mpUpd = ["plugin", "marketplace", "update", "spor"];
1609
- const plUpd = ["plugin", "update", "spor", "--scope", cliScope];
1861
+ // Plugin id is name@marketplace ('spor@spor'); the bare name doesn't resolve
1862
+ // (issue-spor-upgrade-wrong-plugin-marketplace-id). Keep this dry-run preview
1863
+ // in sync with the real call in refreshClaudePlugin().
1864
+ const plUpd = ["plugin", "update", "spor@spor", "--scope", cliScope];
1610
1865
  if (dryRun) {
1611
1866
  out(`would run: ${cmd} ${mpAdd.join(" ")}`);
1612
1867
  out(`would run: ${cmd} ${mpUpd.join(" ")}`);
@@ -1755,22 +2010,45 @@ async function compileBriefing(cfg, { nodeId, query, full, project }) {
1755
2010
  return (r.stdout || "").trim();
1756
2011
  }
1757
2012
 
1758
- // The single highest-ranked open queue item (for --from-queue). Mode-aware,
1759
- // fail-soft (null on any error/empty).
2013
+ // The highest-ranked open queue item for --from-queue — the first that ISN'T
2014
+ // already in flight on THIS machine. Mode-aware, fail-soft (null on any
2015
+ // error/empty). This used to take limit=1 blindly, but the queue's lease filter
2016
+ // is viewer-relative (lib/kernel/queue.js): a lease held by ANOTHER person is
2017
+ // dropped, yet the dispatcher's OWN in-progress claim is kept and floated up by
2018
+ // its `front` signal — so the top item was frequently the caller's own active
2019
+ // work, which the same-machine guard then refused instead of advancing
2020
+ // (task-spor-dispatch-from-queue-skip-in-flight). So pull a page and skip items
2021
+ // with a background agent already running here — dispatchedAgents()/
2022
+ // annotateInFlight, the same NO-LLM, fail-soft cross-reference the same-machine
2023
+ // guard and `spor next --hide-dispatched` use — returning the first not-in-flight
2024
+ // item. If EVERY candidate is in flight, fall back to the top one so the caller's
2025
+ // guard reports it (rather than a misleading "queue empty"). A page (not just the
2026
+ // top) is fetched in BOTH modes; with no agents in flight free[0] is still the
2027
+ // top item, so the prior single-pick behavior is preserved.
1760
2028
  async function topQueueItem(cfg, slug) {
2029
+ const LIMIT = 25;
2030
+ let items = [];
1761
2031
  if (cfg.mode() === "remote") {
1762
- const q = slug ? `?project=${encodeURIComponent(slug)}&limit=1` : "?limit=1";
2032
+ const q = slug ? `?project=${encodeURIComponent(slug)}&limit=${LIMIT}` : `?limit=${LIMIT}`;
1763
2033
  const r = await remote.get(cfg, `/v1/queue${q}`, { timeoutMs: 6000 });
1764
- return r.ok && r.json ? (r.json.items || [])[0] || null : null;
2034
+ items = r.ok && r.json ? r.json.items || [] : [];
2035
+ } else {
2036
+ try {
2037
+ const g = require(path.join(ROOT, "lib", "graph.js")).loadGraph(cfg.nodesDir());
2038
+ const { rankQueue } = require(path.join(ROOT, "lib", "queue.js"));
2039
+ const r = rankQueue(g, slug ? { project: slug, limit: LIMIT } : { limit: LIMIT });
2040
+ items = r.items || [];
2041
+ } catch {
2042
+ items = [];
2043
+ }
1765
2044
  }
1766
- try {
1767
- const g = require(path.join(ROOT, "lib", "graph.js")).loadGraph(cfg.nodesDir());
1768
- const { rankQueue } = require(path.join(ROOT, "lib", "queue.js"));
1769
- const r = rankQueue(g, slug ? { project: slug, limit: 1 } : { limit: 1 });
1770
- return (r.items || [])[0] || null;
1771
- } catch {
1772
- return null;
2045
+ if (!items.length) return null;
2046
+ // Skip items already in flight on this machine; advance to the first free one.
2047
+ const { items: free, hidden } = annotateInFlight(items, dispatchedAgents(), true);
2048
+ if (hidden && free.length) {
2049
+ err(`from-queue: skipped ${hidden} item(s) already in flight on this machine; picking ${free[0].id}`);
1773
2050
  }
2051
+ return free[0] || items[0] || null;
1774
2052
  }
1775
2053
 
1776
2054
  // Auto-claim a dispatched node so its lease is established at dispatch time
@@ -1778,17 +2056,20 @@ async function topQueueItem(cfg, slug) {
1778
2056
  // post-tool heartbeat drives (dec-cc-task-claim-lease, task-cc-claim-nudge-hook).
1779
2057
  // REMOTE-MODE ONLY: a claim is a server-held lease; local mode has no pool or
1780
2058
  // 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 });
2059
+ // local dispatch stays byte-identical. SESSION-BOUND at dispatch
2060
+ // (dec-spor-session-identity-active-record): dispatch now FORCES the session id
2061
+ // (`--session-id <uuid>`), so it knows the working session up front and binds the
2062
+ // lease to it; the launched bg agent runs under that exact session, so its
2063
+ // post-tool heartbeat renews the same-session lease (the earlier person-scoped /
2064
+ // session:null workaround existed only because the old dispatcher did NOT know
2065
+ // the working session that reason is gone). Omitting `session` leaves the lease
2066
+ // person-scoped, the prior behavior. Returns {ok} on success/idempotent-renew,
2067
+ // {conflict, message} when the node is already held (the concurrent-dispatch case
2068
+ // this guards), or {error} for any other failure (fail-open: the caller warns and
2069
+ // dispatches anyway).
2070
+ async function claimDispatch(cfg, nodeId, session) {
2071
+ const body = session ? { session } : {};
2072
+ const r = await remote.post(cfg, `/v1/nodes/${encodeURIComponent(nodeId)}/claim`, body, { timeoutMs: 6000 });
1792
2073
  if (r.ok) return { ok: true, lease: r.json && r.json.lease };
1793
2074
  // 409 = the node can't be claimed right now — almost always a live lease held
1794
2075
  // by ANOTHER person (the concurrent-dispatch conflict this feature exists to
@@ -1887,6 +2168,100 @@ function onboardRepo(cfg, dir) {
1887
2168
  }
1888
2169
  }
1889
2170
 
2171
+ // --- dispatch agent identity (dec-spor-session-identity-active-record) -----
2172
+ // A dispatched session runs AS this machine's agent, carried on a per-session
2173
+ // agent-scoped MCP token (env does NOT propagate through `claude --bg`, so
2174
+ // identity rides the token in --mcp-config, never env). These three helpers are
2175
+ // the verified mechanism; all fail soft so a server without the agent surface,
2176
+ // or a machine with no agent configured, degrades to the prior person-scoped
2177
+ // dispatch with a clear line.
2178
+
2179
+ // This machine's agent node id, or null. A per-machine config key the shared
2180
+ // graph can't hold (like dispatch.repos) — SPOR_DISPATCH_AGENT / .spor.json
2181
+ // {"dispatch":{"agent":"agent-x"}} / user config. null => dispatch without
2182
+ // agent-scoping (graceful, person-attributed as before).
2183
+ function dispatchAgentId(cfg) {
2184
+ return cfg.get("dispatch.agent", null) || null;
2185
+ }
2186
+
2187
+ // Mint a per-session agent-scoped token (dec-spor-session-identity-active-record):
2188
+ // carries the agent (spiffe sub), the person (RFC 8693 act.sub), and the session
2189
+ // id; audience-restricted, short TTL — the server is the CA. SELF-SERVE and
2190
+ // OWNERSHIP-gated, NOT admin-gated: POST /v1/agents/{id}/token authenticated with
2191
+ // the dispatcher's normal person token (SPOR_TOKEN); the server checks the caller
2192
+ // OWNS agent {id} (the owned-by edge) — so a normal teammate can mint a token for
2193
+ // their own machine's agent without being an admin. REMOTE only. Returns
2194
+ // { ok, token } on success, { absent:true } when the mint surface isn't deployed
2195
+ // yet (404 — the spor-server stream is building it; fail soft, dispatch without
2196
+ // agent-scoping), or { error } on any other failure incl. 403/owner-mismatch
2197
+ // (also fail soft — warn and dispatch person-scoped, never block).
2198
+ async function mintAgentToken(cfg, { agent, session }) {
2199
+ const r = await remote.post(cfg, `/v1/agents/${encodeURIComponent(agent)}/token`, { session }, { timeoutMs: 6000 });
2200
+ if (r.transport) return { error: r.error };
2201
+ // 404 = no route (surface not deployed) => absent, dispatch falls back cleanly.
2202
+ if (r.status === 404) return { absent: true };
2203
+ if (!r.ok) return { error: `HTTP ${r.status}${r.json && r.json.error && r.json.error.code ? ` (${r.json.error.code})` : ""}` };
2204
+ const token = r.json && (r.json.token || r.json.access_token);
2205
+ if (!token) return { absent: true };
2206
+ return { ok: true, token };
2207
+ }
2208
+
2209
+ // Write the 0600 --mcp-config JSON that gives the bg agent ONLY its own
2210
+ // agent-scoped Spor MCP (account connector excluded by --strict-mcp-config,
2211
+ // verified #1). Machine-local, gitignored-adjacent path under the user config
2212
+ // home's outbox; per-session filename so concurrent dispatches don't collide.
2213
+ // Returns the file path. The bg agent reads it on startup AFTER this process
2214
+ // exits (claude --bg detaches), so we cannot delete it eagerly — cleanup is a
2215
+ // best-effort sweep of stale files here, plus the documented short-TTL token
2216
+ // inside it (dec-spor-session-identity-active-record open item).
2217
+ function writeDispatchMcpConfig(cfg, { token, session }) {
2218
+ const dir = path.join(cfg.userConfigHome(), "outbox", "dispatch");
2219
+ fs.mkdirSync(dir, { recursive: true });
2220
+ sweepStaleMcpConfigs(dir);
2221
+ const file = path.join(dir, `mcp-${session}.json`);
2222
+ const conf = {
2223
+ mcpServers: {
2224
+ spor: {
2225
+ type: "http",
2226
+ url: `${remote.base(cfg)}/mcp`,
2227
+ headers: { Authorization: `Bearer ${token}` },
2228
+ },
2229
+ },
2230
+ };
2231
+ // 0600: the file holds a live bearer token. Create with O_EXCL (wx) so a
2232
+ // pre-placed file or symlink at this path is REFUSED rather than written
2233
+ // through, and the file is 0600 from creation (no widen-then-narrow window).
2234
+ // The session-uuid filename makes a real collision a non-issue; a stale
2235
+ // leftover was swept above.
2236
+ const fd = fs.openSync(file, "wx", 0o600);
2237
+ try {
2238
+ fs.writeSync(fd, JSON.stringify(conf, null, 2) + "\n");
2239
+ } finally {
2240
+ fs.closeSync(fd);
2241
+ }
2242
+ return file;
2243
+ }
2244
+
2245
+ // Best-effort cleanup: remove dispatch mcp-config files older than a day. The
2246
+ // tokens inside are short-TTL, but the files linger because claude --bg reads
2247
+ // them after we exit; sweep on the next dispatch so they don't accumulate.
2248
+ function sweepStaleMcpConfigs(dir) {
2249
+ try {
2250
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
2251
+ for (const f of fs.readdirSync(dir)) {
2252
+ if (!/^mcp-.*\.json$/.test(f)) continue;
2253
+ const p = path.join(dir, f);
2254
+ try {
2255
+ if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p);
2256
+ } catch {
2257
+ /* racing another dispatch — ignore */
2258
+ }
2259
+ }
2260
+ } catch {
2261
+ /* dir vanished or unreadable — nothing to sweep */
2262
+ }
2263
+ }
2264
+
1890
2265
  async function cmdDispatch(cfg, { values, positionals: pos }) {
1891
2266
  const dryRun = !!(values.print || values["dry-run"]);
1892
2267
  const full = !!values.full;
@@ -1898,7 +2273,8 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
1898
2273
  const dirOpt = values.dir || null;
1899
2274
  const model = values.model || null;
1900
2275
  const permMode = values["permission-mode"] || null;
1901
- const agent = values.agent || null;
2276
+ const agent = values.agent || null; // claude --agent (harness agent DEFINITION)
2277
+ const asAgent = values.as || null; // Spor agent IDENTITY override for dispatch.agent
1902
2278
  // A user-supplied prompt template (task-spor-dispatch-user-prompt-templates):
1903
2279
  // --template wins, else a personal default in the config cascade
1904
2280
  // (dispatch.template — an absolute path, like dispatch.repos). Empty until we
@@ -2020,13 +2396,38 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2020
2396
  // exit / unparseable output => empty => no guard (fail-open); --force overrides.
2021
2397
  const inFlight = nodeId && !backfill ? dispatchedAgents().get(name) || [] : [];
2022
2398
 
2399
+ // Session identity (dec-spor-session-identity-active-record). Force the
2400
+ // session id up front — `--session-id <uuid>` makes the dispatched run's
2401
+ // session deterministic, so the agent-scoped token below can bind it AND the
2402
+ // claim can be session-bound at dispatch (the bg agent's post-tool heartbeat
2403
+ // renews under this same id). SPOR_SESSION_ID pins it for tests/reproducibility.
2404
+ const session = process.env.SPOR_SESSION_ID || crypto.randomUUID();
2405
+ // This machine's agent node — the WHO a dispatched session runs as. `--as`
2406
+ // overrides the per-machine dispatch.agent default for this one dispatch (its
2407
+ // id format mirrors a node id; a bad/unowned one fails soft at token-mint
2408
+ // below, like the config default). Only meaningful remotely (the server is the
2409
+ // CA that mints the agent token); a local-mode dispatch or an unconfigured
2410
+ // machine simply runs person-scoped.
2411
+ if (asAgent && !/^[a-z0-9][a-z0-9-]*$/.test(asAgent)) {
2412
+ err(`invalid --as agent id '${asAgent}' — must match ^[a-z0-9][a-z0-9-]*$ (e.g. agent-your-machine)`);
2413
+ return 1;
2414
+ }
2415
+ const identityAgent = cfg.mode() === "remote" ? (asAgent || dispatchAgentId(cfg)) : null;
2416
+ // An explicit --as can't take effect in local mode — there is no CA to mint the
2417
+ // agent token. Say so rather than silently dropping it to person-scoped.
2418
+ if (asAgent && cfg.mode() !== "remote") {
2419
+ err(`note: --as ${asAgent} ignored in local mode — agent-on-behalf-of attribution is remote-only`);
2420
+ }
2421
+
2023
2422
  const claudeBin = claudeCmd();
2024
2423
  const claudeArgs = ["--bg"];
2025
2424
  if (name) claudeArgs.push("--name", name);
2026
2425
  if (model) claudeArgs.push("--model", model);
2027
2426
  if (permMode) claudeArgs.push("--permission-mode", permMode);
2028
2427
  if (agent) claudeArgs.push("--agent", agent);
2029
- claudeArgs.push(prompt);
2428
+ // Force the session id on every dispatch (local or remote). It is the run leaf
2429
+ // of the agent's identity and what the session-bound claim + heartbeat key on.
2430
+ claudeArgs.push("--session-id", session);
2030
2431
 
2031
2432
  if (dryRun) {
2032
2433
  out(`dir: ${res.dir} (slug: ${res.slug}, via ${res.source})`);
@@ -2038,6 +2439,18 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2038
2439
  out(`onboard: ${steps.join("; ")}`);
2039
2440
  }
2040
2441
  out(`brief: ${brief ? `${brief.length} bytes` : "(none — graph had nothing relevant, or --no-brief/--backfill)"}`);
2442
+ out(`session: ${session}`);
2443
+ // Identity preview: what the real dispatch would do for agent-scoping. The
2444
+ // token mint + 0600 mcp-config are SIDE EFFECTS, so --print only describes
2445
+ // them (it writes nothing and makes no network call here). Local mode and an
2446
+ // unconfigured machine read "person-scoped" — byte-stable but for the new
2447
+ // session line, which is additive and always present now.
2448
+ if (identityAgent) {
2449
+ const src = asAgent ? " (via --as)" : "";
2450
+ out(`agent: ${identityAgent}${src} (would mint a per-session agent-scoped token + write a 0600 --mcp-config, then add --strict-mcp-config)`);
2451
+ } else if (cfg.mode() === "remote") {
2452
+ out(`agent: (none configured — 'spor agent use agent-<machine>' or --as to attribute as agent-on-behalf-of; dispatching person-scoped)`);
2453
+ }
2041
2454
  // Same-machine guard preview (node mode, any mode): a real dispatch would
2042
2455
  // refuse if an agent with this name is already in flight here. Shown only on a
2043
2456
  // hit, so a clean node --print stays byte-identical to before.
@@ -2050,10 +2463,10 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2050
2463
  // Auto-claim preview (remote node dispatch only — local mode has no lease, so
2051
2464
  // nothing is announced there and local --print stays byte-identical).
2052
2465
  if (nodeId && !backfill && cfg.mode() === "remote") {
2053
- out(`claim: ${noClaim ? "(--no-claim — lease not established)" : `would establish a lease on ${nodeId} at launch`}`);
2466
+ out(`claim: ${noClaim ? "(--no-claim — lease not established)" : `would establish a session-bound lease on ${nodeId} at launch`}`);
2054
2467
  }
2055
2468
  if (template != null) out(`template: ${path.resolve(templateOpt)}`);
2056
- out(`run: ${claudeBin} ${claudeArgs.slice(0, -1).map(shellQuote).join(" ")} <prompt>`);
2469
+ out(`run: ${claudeBin} ${claudeArgs.map(shellQuote).join(" ")} <prompt>`);
2057
2470
  out(`\n--- prompt ---\n${prompt}`);
2058
2471
  return 0;
2059
2472
  }
@@ -2082,23 +2495,49 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2082
2495
  err("claude CLI not on PATH — install Claude Code, then re-run (or 'spor dispatch … --print' to see the prompt).");
2083
2496
  return 1;
2084
2497
  }
2498
+
2499
+ // Agent-scoped identity injection (dec-spor-session-identity-active-record,
2500
+ // the VERIFIED mechanism): mint a per-session agent-scoped token, write it into
2501
+ // a 0600 --mcp-config that exposes ONLY the agent's own Spor MCP, and add
2502
+ // --strict-mcp-config so the account connector is excluded by construction. The
2503
+ // server then stamps authored_by_agent + session from that token. FAIL SOFT at
2504
+ // every step — a server without the mint surface, or a transient error, falls
2505
+ // back to the prior person-scoped dispatch with a clear line. Remote + a
2506
+ // configured agent only; local/unconfigured dispatch is byte-identical but for
2507
+ // the always-present --session-id.
2508
+ if (identityAgent) {
2509
+ const mint = await mintAgentToken(cfg, { agent: identityAgent, session });
2510
+ if (mint.ok) {
2511
+ const mcpFile = writeDispatchMcpConfig(cfg, { token: mint.token, session });
2512
+ claudeArgs.push("--mcp-config", mcpFile, "--strict-mcp-config");
2513
+ out(`agent: ${identityAgent} (session ${session} — writes attributed agent-on-behalf-of-you)`);
2514
+ } else if (mint.absent) {
2515
+ err(`warning: this server can't mint agent-scoped session tokens yet — dispatching person-scoped (session ${session}).`);
2516
+ } else {
2517
+ err(`warning: could not mint an agent token (${mint.error}) — dispatching person-scoped (session ${session}).`);
2518
+ }
2519
+ }
2520
+
2085
2521
  // Establish the claim/lease BEFORE launching (task-spor-dispatch-auto-claim):
2086
2522
  // a node already claimed by someone else is caught here, so we never launch a
2087
2523
  // duplicate agent onto contested work, and the lease is live the moment the
2088
2524
  // agent starts (its post-tool writes then renew it — and seeing its own held
2089
2525
  // claim, it skips the redundant claim-nudge). Remote node-mode only; --no-claim
2090
- // opts out (dispatch with no lease, the prior behavior).
2526
+ // opts out (dispatch with no lease, the prior behavior). Now SESSION-BOUND
2527
+ // (dec-spor-session-identity-active-record): dispatch forces the session id, so
2528
+ // the lease binds it and the bg agent's same-session heartbeat renews it.
2091
2529
  if (nodeId && !backfill && !noClaim && cfg.mode() === "remote") {
2092
- const c = await claimDispatch(cfg, nodeId);
2530
+ const c = await claimDispatch(cfg, nodeId, session);
2093
2531
  if (c.conflict) {
2094
2532
  err(`${nodeId} is already claimed — ${c.message}`);
2095
2533
  err(` not dispatching a duplicate. Re-run with --no-claim to dispatch anyway (no lease),`);
2096
2534
  err(` or pick another task with 'spor next'.`);
2097
2535
  return 1;
2098
2536
  }
2099
- if (c.ok) out(`claimed ${nodeId} (lease established; the agent's writes will renew it)`);
2537
+ if (c.ok) out(`claimed ${nodeId} (session-bound lease established; the agent's writes will renew it)`);
2100
2538
  else err(`warning: could not establish a lease on ${nodeId}: ${c.error} — dispatching without a claim`);
2101
2539
  }
2540
+ claudeArgs.push(prompt);
2102
2541
  const r = spawnSync(claudeBin, claudeArgs, { cwd: res.dir, stdio: "inherit" });
2103
2542
  if (r.error) {
2104
2543
  err(`could not launch ${claudeBin}: ${r.error.message}`);
@@ -2282,6 +2721,30 @@ const COMMANDS = {
2282
2721
  examples: ["spor token list", "spor token revoke a1b2c3"],
2283
2722
  run: (cfg, args) => cmdToken(cfg, args),
2284
2723
  },
2724
+ agent: {
2725
+ group: "Team admin (remote, admin token)", parse: "raw", args: "create <label> [--owner <id>] [--pubkey <fp>] | list | use <agent-id>",
2726
+ summary: "person-owned automation principals (dispatch identity)",
2727
+ help:
2728
+ "Create and list agents — first-class `type: agent` nodes owned by a person\n" +
2729
+ "(dec-spor-agent-identity-nodes). A dispatched session runs AS its agent, so its\n" +
2730
+ "writes read \"agent on behalf of person\" rather than person-direct. One durable\n" +
2731
+ "agent per machine/install, reused across dispatches.\n\n" +
2732
+ " spor agent create <label> create the agent + its owned-by edge to a person\n" +
2733
+ " --owner <person-id> owner (remote: defaults to your person; local:\n" +
2734
+ " defaults to the sole person node, else required)\n" +
2735
+ " --pubkey <fingerprint> record a public-key fingerprint (forward-compat,\n" +
2736
+ " unenforced — may be omitted)\n" +
2737
+ " spor agent list list agents and their owners\n" +
2738
+ " spor agent use <agent-id> make it THIS machine's default dispatch identity\n" +
2739
+ " (writes dispatch.agent to your user config; pass\n" +
2740
+ " --clear to go back to person-scoped dispatch)\n\n" +
2741
+ "'use' is a local config write, not a graph write — it sets which agent\n" +
2742
+ "`spor dispatch` runs as by default (override one dispatch with 'dispatch --as').\n" +
2743
+ "Create/list run remote (POST /v1/admin/agents, admin-gated, the server mints the\n" +
2744
+ "spiffe); local mode writes the node + owned-by edge to the graph home.",
2745
+ examples: ["spor agent create anthony-laptop", "spor agent use agent-anthony-laptop", "spor agent list"],
2746
+ run: (cfg, args) => cmdAgent(cfg, args),
2747
+ },
2285
2748
 
2286
2749
  // --- Graph ---
2287
2750
  add: {
@@ -2435,7 +2898,8 @@ const COMMANDS = {
2435
2898
  help:
2436
2899
  "Compile a briefing for a task and launch a Claude Code background agent in the\n" +
2437
2900
  "right repo. Give free-text, a <node-id>, --node <id>, --from-queue (the top\n" +
2438
- "ranked item), or --backfill (onboard/repair a repo). The target dir is the\n" +
2901
+ "ranked item NOT already in flight on this machine), or --backfill (onboard/\n" +
2902
+ "repair a repo). The target dir is the\n" +
2439
2903
  "slug->path map ('spor repos'), overridable with --dir.\n\n" +
2440
2904
  "In remote mode a node dispatch auto-claims the task — it establishes the\n" +
2441
2905
  "heartbeat lease at dispatch time, so concurrent dispatch of the same node is\n" +
@@ -2444,21 +2908,27 @@ const COMMANDS = {
2444
2908
  "on THIS machine (each agent is named after its node id) — catches the\n" +
2445
2909
  "same-person duplicate the lease's idempotent renew can't. --force overrides.\n\n" +
2446
2910
  "--template supplies your own prompt with {{brief}}/{{task}}/{{node}}/{{title}}/\n" +
2447
- "{{slug}}/{{dir}}/{{default}} placeholders.",
2911
+ "{{slug}}/{{dir}}/{{default}} placeholders.\n\n" +
2912
+ "Two different 'agent' axes, don't confuse them: --as picks the Spor agent\n" +
2913
+ "IDENTITY the dispatch runs AS (attribution 'agent on behalf of person',\n" +
2914
+ "remote-only; defaults to dispatch.agent — set it with 'spor agent use <id>').\n" +
2915
+ "--agent is the unrelated 'claude --agent' passthrough that picks the harness\n" +
2916
+ "agent DEFINITION (subagent personality/toolset) the background session runs.",
2448
2917
  options: {
2449
2918
  dir: { type: "string", value: "path", desc: "launch directory (overrides the slug map)" },
2450
2919
  node: { type: "string", value: "id", desc: "dispatch a specific node id" },
2451
2920
  slug: { type: "string", value: "slug", desc: "target project slug (cross-repo resolution)" },
2921
+ as: { type: "string", value: "agent-id", desc: "Spor agent IDENTITY to run as (overrides dispatch.agent; remote-only)" },
2452
2922
  model: { type: "string", value: "M", desc: "claude --model" },
2453
2923
  "permission-mode": { type: "string", value: "P", desc: "claude --permission-mode" },
2454
- agent: { type: "string", value: "A", desc: "claude --agent" },
2924
+ agent: { type: "string", value: "A", desc: "claude --agent (harness agent DEFINITION — NOT the Spor identity; see --as)" },
2455
2925
  name: { type: "string", value: "N", desc: "claude --name (session name)" },
2456
2926
  template: { type: "string", value: "F", desc: "prompt template file (placeholders above)" },
2457
2927
  full: { type: "boolean", desc: "full briefing instead of the digest" },
2458
2928
  "no-brief": { type: "boolean", desc: "raw task prompt, no briefing block" },
2459
2929
  "no-claim": { type: "boolean", desc: "don't auto-claim the lease (remote node dispatch)" },
2460
2930
  force: { type: "boolean", desc: "dispatch even if an agent for this node is already in flight here" },
2461
- "from-queue": { type: "boolean", desc: "dispatch the top-ranked queue item" },
2931
+ "from-queue": { type: "boolean", desc: "dispatch the top-ranked queue item not already in flight here" },
2462
2932
  backfill: { type: "boolean", desc: "onboard/repair this repo (runs /spor:backfill)" },
2463
2933
  print: { type: "boolean", desc: "dry run — print the prompt, launch nothing" },
2464
2934
  "dry-run": DRYRUN_OPT,
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.1",
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",
@@ -46,6 +46,7 @@
46
46
  ],
47
47
  "scripts": {
48
48
  "test": "node --test test/*.test.js",
49
+ "test:e2e": "node --test test/e2e-claude.test.js",
49
50
  "conformance": "node conformance/runner.js"
50
51
  },
51
52
  "engines": {
@@ -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
 
@@ -9,8 +9,15 @@ const fs = require("fs");
9
9
  const path = require("path");
10
10
  const u = require("./util");
11
11
 
12
+ // The /spor:spor preload instruction LEADS every usage string (and so every
13
+ // session-start envelope, all modes): Spor's CLI syntax, node/edge format, and
14
+ // MCP/REST surface aren't in the model's training, so any graph operation
15
+ // attempted without first loading the skill tends to invent syntax. Stating it
16
+ // as a standing precondition here is cheaper than the agent rediscovering it
17
+ // per session. Phrased as "before any operation on the graph" to match the
18
+ // skill's own trigger.
12
19
  const USAGE =
13
- "Use /spor:brief <query or node-id> for a task-specific briefing, /spor:correct to fix a bad briefing.";
20
+ "Before any operation on the Spor graph (searching/querying, reading or writing nodes, adding edges, capturing, or running spor CLI/MCP tools), load the /spor:spor skill first — it carries the CLI syntax, node/edge format, and tool surface your training doesn't cover. Use /spor:brief <query or node-id> for a task-specific briefing, /spor:correct to fix a bad briefing.";
14
21
  const USAGE_REMOTE =
15
22
  USAGE +
16
23
  " When you defer discovered work mid-task (out-of-scope fix, follow-up, dismissed approach), capture it the moment you defer it: /spor:defer <2-3 sentences, what + why> — one call, the server types and links it.";
@@ -490,6 +490,43 @@ function forgetRepo(graphHomeDir, slug) {
490
490
  });
491
491
  }
492
492
 
493
+ // Record this machine's default dispatch identity (`dispatch.agent`) into the
494
+ // SAME user config.json as the repo map — the per-machine key `spor dispatch`
495
+ // reads to attribute a dispatched session "agent on behalf of person". Scalar
496
+ // sibling of registerRepo: agentId is an `agent-...` node id, or null/"" to
497
+ // clear. Returns true only when it actually wrote; refuses to clobber a
498
+ // present-but-malformed config (same fail-safe as editRepoMap).
499
+ function setDispatchAgent(graphHomeDir, agentId) {
500
+ try {
501
+ const file = userConfigPath(graphHomeDir);
502
+ let raw = null;
503
+ try {
504
+ raw = fs.readFileSync(file, "utf8");
505
+ } catch {
506
+ raw = null; // absent — start fresh
507
+ }
508
+ let data = {};
509
+ if (raw != null) {
510
+ try {
511
+ data = JSON.parse(raw);
512
+ } catch {
513
+ return false; // malformed — do NOT overwrite
514
+ }
515
+ if (data == null || typeof data !== "object" || Array.isArray(data)) data = {};
516
+ }
517
+ if (data.dispatch == null || typeof data.dispatch !== "object" || Array.isArray(data.dispatch)) data.dispatch = {};
518
+ const next = agentId || null;
519
+ if ((data.dispatch.agent || null) === next) return false; // unchanged — skip the write
520
+ if (next == null) delete data.dispatch.agent;
521
+ else data.dispatch.agent = next;
522
+ if (!ensureDir(graphHomeDir)) return false;
523
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
524
+ return true;
525
+ } catch {
526
+ return false;
527
+ }
528
+ }
529
+
493
530
  function appendLine(file, line) {
494
531
  try {
495
532
  fs.appendFileSync(file, line + "\n");
@@ -780,6 +817,7 @@ module.exports = {
780
817
  ensureGraphGitignore,
781
818
  registerRepo,
782
819
  forgetRepo,
820
+ setDispatchAgent,
783
821
  appendLine,
784
822
  makeLogger,
785
823
  loadGraphCached,
@@ -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,13 @@ 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 agent use <agent-id> # make it THIS machine's default dispatch identity (writes dispatch.agent)
90
+ spor dispatch <id>|"<task>" # run work as a background agent; in team mode its graph writes are
91
+ # attributed "agent on behalf of you" (the machine's default agent =
92
+ # dispatch.agent, set by `spor agent use`; --as <agent-id> overrides it
93
+ # per dispatch; --agent is the unrelated `claude --agent` passthrough).
94
+ # See API.md §3-§4.
88
95
 
89
96
  # dual-mode (local passthrough / remote dispatch to the server)
90
97
  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