@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.
- package/.claude-plugin/plugin.json +1 -1
- package/API.md +26 -3
- package/GRAPH.md +52 -0
- package/bin/spor.js +505 -35
- package/lib/config.js +1 -0
- package/lib/graph.js +2 -0
- package/lib/kernel/graph.js +27 -1
- package/lib/seed/schema-agent.md +67 -0
- package/lib/seed/schema-edge-owned-by.md +37 -0
- package/package.json +2 -1
- package/scripts/engines/prompt-context.js +11 -1
- package/scripts/engines/session-start.js +8 -1
- package/scripts/engines/util.js +38 -0
- package/skills/brief/SKILL.md +20 -7
- package/skills/spor/SKILL.md +7 -0
- package/skills/spor/references/concepts.md +6 -2
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1759
|
-
// fail-soft (null on any
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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.
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1786
|
-
//
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
1789
|
-
//
|
|
1790
|
-
|
|
1791
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
package/lib/kernel/graph.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.";
|
package/scripts/engines/util.js
CHANGED
|
@@ -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,
|
package/skills/brief/SKILL.md
CHANGED
|
@@ -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
|
|
30
|
-
field is the compiled neighborhood. A
|
|
31
|
-
graph has nothing relevant — say so and
|
|
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
|
|
34
|
-
neighborhood. In Cowork, call the
|
|
35
|
-
a node id, or `query` for free
|
|
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
|
package/skills/spor/SKILL.md
CHANGED
|
@@ -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
|
|
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
|