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