@sporhq/spor 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,6 @@
2
2
  "name": "spor",
3
3
  "displayName": "Spor Context Compiler",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
- "version": "0.7.0",
5
+ "version": "0.7.1",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/API.md CHANGED
@@ -376,8 +376,10 @@ anything with a token.
376
376
  agent-on-behalf-of-person (§1) — the `person → agent` chain is the audit
377
377
  trail. `spor dispatch` mints one per run and injects it into the launched
378
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`).
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.)
381
383
  - **OAuth 2.1 for MCP connectors** (Cowork/claude.ai, which cannot carry a
382
384
  static bearer token): protected-resource metadata discovery (RFC 9728,
383
385
  advertised on the `/mcp` 401 via `WWW-Authenticate`), authorization-server
package/bin/spor.js CHANGED
@@ -977,10 +977,45 @@ async function cmdAgent(cfg, args) {
977
977
  if (!sub || sub === "list") {
978
978
  return cfg.mode() === "remote" ? cmdAgentListRemote(cfg) : cmdAgentListLocal(cfg);
979
979
  }
980
- err("usage: spor agent create <label> [--owner person-x] [--pubkey <fp>] | spor agent list");
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>");
981
984
  return 1;
982
985
  }
983
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
+
984
1019
  async function cmdAgentCreateRemote(cfg, { label, owner, pubkey }) {
985
1020
  const body = { label };
986
1021
  if (owner) body.owner = owner;
@@ -1009,7 +1044,8 @@ async function cmdAgentCreateRemote(cfg, { label, owner, pubkey }) {
1009
1044
  const j = r.json || {};
1010
1045
  out(`created agent ${j.id || `agent-${kebab(label)}`}${j.owner ? ` owned by ${j.owner}` : ""}`);
1011
1046
  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)`);
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)}`} …`);
1013
1049
  return 0;
1014
1050
  }
1015
1051
 
@@ -1080,7 +1116,8 @@ async function cmdAgentCreateLocal(cfg, { label, owner, pubkey }) {
1080
1116
  fs.writeFileSync(path.join(nodesDir, `${id}.md`), md);
1081
1117
  out(`created agent ${id} owned by ${ownerId}`);
1082
1118
  out(` spiffe: ${spiffe}`);
1083
- out(` dispatch with this agent: set dispatch.agent=${id} (spor repos config), then 'spor dispatch …'`);
1119
+ out(` make it this machine's default: spor agent use ${id}`);
1120
+ out(` (note: agent-on-behalf-of attribution applies in remote mode)`);
1084
1121
  return 0;
1085
1122
  }
1086
1123
 
@@ -1633,7 +1670,10 @@ function detectHosts() {
1633
1670
  // already ensured the claude CLI exists and the marketplace is registered.
1634
1671
  function refreshClaudePlugin(cmd, cliScope, before) {
1635
1672
  spawnSync(cmd, ["plugin", "marketplace", "update", "spor"], { encoding: "utf8" });
1636
- const upd = spawnSync(cmd, ["plugin", "update", "spor", "--scope", cliScope], { stdio: "inherit" });
1673
+ // Claude Code resolves an installed plugin by its name@marketplace id (the
1674
+ // install side uses 'spor@spor'); the bare 'spor' is unresolvable and fails
1675
+ // with "Plugin 'spor' not found" (issue-spor-upgrade-wrong-plugin-marketplace-id).
1676
+ const upd = spawnSync(cmd, ["plugin", "update", "spor@spor", "--scope", cliScope], { stdio: "inherit" });
1637
1677
  if (upd.status !== 0) {
1638
1678
  err(`claude plugin update failed (exit ${upd.status == null ? "?" : upd.status})`);
1639
1679
  return 1;
@@ -1818,7 +1858,10 @@ function upgradeClaude(scope, dryRun) {
1818
1858
  const cliScope = scope === "repo" ? "project" : "user";
1819
1859
  const mpAdd = ["plugin", "marketplace", "add", ROOT];
1820
1860
  const mpUpd = ["plugin", "marketplace", "update", "spor"];
1821
- const plUpd = ["plugin", "update", "spor", "--scope", cliScope];
1861
+ // Plugin id is name@marketplace ('spor@spor'); the bare name doesn't resolve
1862
+ // (issue-spor-upgrade-wrong-plugin-marketplace-id). Keep this dry-run preview
1863
+ // in sync with the real call in refreshClaudePlugin().
1864
+ const plUpd = ["plugin", "update", "spor@spor", "--scope", cliScope];
1822
1865
  if (dryRun) {
1823
1866
  out(`would run: ${cmd} ${mpAdd.join(" ")}`);
1824
1867
  out(`would run: ${cmd} ${mpUpd.join(" ")}`);
@@ -1967,22 +2010,45 @@ async function compileBriefing(cfg, { nodeId, query, full, project }) {
1967
2010
  return (r.stdout || "").trim();
1968
2011
  }
1969
2012
 
1970
- // The single highest-ranked open queue item (for --from-queue). Mode-aware,
1971
- // fail-soft (null on any error/empty).
2013
+ // The highest-ranked open queue item for --from-queue — the first that ISN'T
2014
+ // already in flight on THIS machine. Mode-aware, fail-soft (null on any
2015
+ // error/empty). This used to take limit=1 blindly, but the queue's lease filter
2016
+ // is viewer-relative (lib/kernel/queue.js): a lease held by ANOTHER person is
2017
+ // dropped, yet the dispatcher's OWN in-progress claim is kept and floated up by
2018
+ // its `front` signal — so the top item was frequently the caller's own active
2019
+ // work, which the same-machine guard then refused instead of advancing
2020
+ // (task-spor-dispatch-from-queue-skip-in-flight). So pull a page and skip items
2021
+ // with a background agent already running here — dispatchedAgents()/
2022
+ // annotateInFlight, the same NO-LLM, fail-soft cross-reference the same-machine
2023
+ // guard and `spor next --hide-dispatched` use — returning the first not-in-flight
2024
+ // item. If EVERY candidate is in flight, fall back to the top one so the caller's
2025
+ // guard reports it (rather than a misleading "queue empty"). A page (not just the
2026
+ // top) is fetched in BOTH modes; with no agents in flight free[0] is still the
2027
+ // top item, so the prior single-pick behavior is preserved.
1972
2028
  async function topQueueItem(cfg, slug) {
2029
+ const LIMIT = 25;
2030
+ let items = [];
1973
2031
  if (cfg.mode() === "remote") {
1974
- const q = slug ? `?project=${encodeURIComponent(slug)}&limit=1` : "?limit=1";
2032
+ const q = slug ? `?project=${encodeURIComponent(slug)}&limit=${LIMIT}` : `?limit=${LIMIT}`;
1975
2033
  const r = await remote.get(cfg, `/v1/queue${q}`, { timeoutMs: 6000 });
1976
- return r.ok && r.json ? (r.json.items || [])[0] || null : null;
2034
+ items = r.ok && r.json ? r.json.items || [] : [];
2035
+ } else {
2036
+ try {
2037
+ const g = require(path.join(ROOT, "lib", "graph.js")).loadGraph(cfg.nodesDir());
2038
+ const { rankQueue } = require(path.join(ROOT, "lib", "queue.js"));
2039
+ const r = rankQueue(g, slug ? { project: slug, limit: LIMIT } : { limit: LIMIT });
2040
+ items = r.items || [];
2041
+ } catch {
2042
+ items = [];
2043
+ }
1977
2044
  }
1978
- try {
1979
- const g = require(path.join(ROOT, "lib", "graph.js")).loadGraph(cfg.nodesDir());
1980
- const { rankQueue } = require(path.join(ROOT, "lib", "queue.js"));
1981
- const r = rankQueue(g, slug ? { project: slug, limit: 1 } : { limit: 1 });
1982
- return (r.items || [])[0] || null;
1983
- } catch {
1984
- 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}`);
1985
2050
  }
2051
+ return free[0] || items[0] || null;
1986
2052
  }
1987
2053
 
1988
2054
  // Auto-claim a dispatched node so its lease is established at dispatch time
@@ -2207,7 +2273,8 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2207
2273
  const dirOpt = values.dir || null;
2208
2274
  const model = values.model || null;
2209
2275
  const permMode = values["permission-mode"] || null;
2210
- 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
2211
2278
  // A user-supplied prompt template (task-spor-dispatch-user-prompt-templates):
2212
2279
  // --template wins, else a personal default in the config cascade
2213
2280
  // (dispatch.template — an absolute path, like dispatch.repos). Empty until we
@@ -2335,10 +2402,22 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2335
2402
  // claim can be session-bound at dispatch (the bg agent's post-tool heartbeat
2336
2403
  // renews under this same id). SPOR_SESSION_ID pins it for tests/reproducibility.
2337
2404
  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;
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
+ }
2342
2421
 
2343
2422
  const claudeBin = claudeCmd();
2344
2423
  const claudeArgs = ["--bg"];
@@ -2367,9 +2446,10 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2367
2446
  // unconfigured machine read "person-scoped" — byte-stable but for the new
2368
2447
  // session line, which is additive and always present now.
2369
2448
  if (identityAgent) {
2370
- out(`agent: ${identityAgent} (would mint a per-session agent-scoped token + write a 0600 --mcp-config, then add --strict-mcp-config)`);
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)`);
2371
2451
  } 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)`);
2452
+ out(`agent: (none configured — 'spor agent use agent-<machine>' or --as to attribute as agent-on-behalf-of; dispatching person-scoped)`);
2373
2453
  }
2374
2454
  // Same-machine guard preview (node mode, any mode): a real dispatch would
2375
2455
  // refuse if an agent with this name is already in flight here. Shown only on a
@@ -2642,7 +2722,7 @@ const COMMANDS = {
2642
2722
  run: (cfg, args) => cmdToken(cfg, args),
2643
2723
  },
2644
2724
  agent: {
2645
- group: "Team admin (remote, admin token)", parse: "raw", args: "create <label> [--owner <id>] [--pubkey <fp>] | list",
2725
+ group: "Team admin (remote, admin token)", parse: "raw", args: "create <label> [--owner <id>] [--pubkey <fp>] | list | use <agent-id>",
2646
2726
  summary: "person-owned automation principals (dispatch identity)",
2647
2727
  help:
2648
2728
  "Create and list agents — first-class `type: agent` nodes owned by a person\n" +
@@ -2654,10 +2734,15 @@ const COMMANDS = {
2654
2734
  " defaults to the sole person node, else required)\n" +
2655
2735
  " --pubkey <fingerprint> record a public-key fingerprint (forward-compat,\n" +
2656
2736
  " 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"],
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"],
2661
2746
  run: (cfg, args) => cmdAgent(cfg, args),
2662
2747
  },
2663
2748
 
@@ -2813,7 +2898,8 @@ const COMMANDS = {
2813
2898
  help:
2814
2899
  "Compile a briefing for a task and launch a Claude Code background agent in the\n" +
2815
2900
  "right repo. Give free-text, a <node-id>, --node <id>, --from-queue (the top\n" +
2816
- "ranked item), or --backfill (onboard/repair a repo). The target dir is the\n" +
2901
+ "ranked item NOT already in flight on this machine), or --backfill (onboard/\n" +
2902
+ "repair a repo). The target dir is the\n" +
2817
2903
  "slug->path map ('spor repos'), overridable with --dir.\n\n" +
2818
2904
  "In remote mode a node dispatch auto-claims the task — it establishes the\n" +
2819
2905
  "heartbeat lease at dispatch time, so concurrent dispatch of the same node is\n" +
@@ -2822,21 +2908,27 @@ const COMMANDS = {
2822
2908
  "on THIS machine (each agent is named after its node id) — catches the\n" +
2823
2909
  "same-person duplicate the lease's idempotent renew can't. --force overrides.\n\n" +
2824
2910
  "--template supplies your own prompt with {{brief}}/{{task}}/{{node}}/{{title}}/\n" +
2825
- "{{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.",
2826
2917
  options: {
2827
2918
  dir: { type: "string", value: "path", desc: "launch directory (overrides the slug map)" },
2828
2919
  node: { type: "string", value: "id", desc: "dispatch a specific node id" },
2829
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)" },
2830
2922
  model: { type: "string", value: "M", desc: "claude --model" },
2831
2923
  "permission-mode": { type: "string", value: "P", desc: "claude --permission-mode" },
2832
- 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)" },
2833
2925
  name: { type: "string", value: "N", desc: "claude --name (session name)" },
2834
2926
  template: { type: "string", value: "F", desc: "prompt template file (placeholders above)" },
2835
2927
  full: { type: "boolean", desc: "full briefing instead of the digest" },
2836
2928
  "no-brief": { type: "boolean", desc: "raw task prompt, no briefing block" },
2837
2929
  "no-claim": { type: "boolean", desc: "don't auto-claim the lease (remote node dispatch)" },
2838
2930
  force: { type: "boolean", desc: "dispatch even if an agent for this node is already in flight here" },
2839
- "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" },
2840
2932
  backfill: { type: "boolean", desc: "onboard/repair this repo (runs /spor:backfill)" },
2841
2933
  print: { type: "boolean", desc: "dry run — print the prompt, launch nothing" },
2842
2934
  "dry-run": DRYRUN_OPT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.7.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": {
@@ -9,8 +9,15 @@ const fs = require("fs");
9
9
  const path = require("path");
10
10
  const u = require("./util");
11
11
 
12
+ // The /spor:spor preload instruction LEADS every usage string (and so every
13
+ // session-start envelope, all modes): Spor's CLI syntax, node/edge format, and
14
+ // MCP/REST surface aren't in the model's training, so any graph operation
15
+ // attempted without first loading the skill tends to invent syntax. Stating it
16
+ // as a standing precondition here is cheaper than the agent rediscovering it
17
+ // per session. Phrased as "before any operation on the graph" to match the
18
+ // skill's own trigger.
12
19
  const USAGE =
13
- "Use /spor:brief <query or node-id> for a task-specific briefing, /spor:correct to fix a bad briefing.";
20
+ "Before any operation on the Spor graph (searching/querying, reading or writing nodes, adding edges, capturing, or running spor CLI/MCP tools), load the /spor:spor skill first — it carries the CLI syntax, node/edge format, and tool surface your training doesn't cover. Use /spor:brief <query or node-id> for a task-specific briefing, /spor:correct to fix a bad briefing.";
14
21
  const USAGE_REMOTE =
15
22
  USAGE +
16
23
  " When you defer discovered work mid-task (out-of-scope fix, follow-up, dismissed approach), capture it the moment you defer it: /spor:defer <2-3 sentences, what + why> — one call, the server types and links it.";
@@ -490,6 +490,43 @@ function forgetRepo(graphHomeDir, slug) {
490
490
  });
491
491
  }
492
492
 
493
+ // Record this machine's default dispatch identity (`dispatch.agent`) into the
494
+ // SAME user config.json as the repo map — the per-machine key `spor dispatch`
495
+ // reads to attribute a dispatched session "agent on behalf of person". Scalar
496
+ // sibling of registerRepo: agentId is an `agent-...` node id, or null/"" to
497
+ // clear. Returns true only when it actually wrote; refuses to clobber a
498
+ // present-but-malformed config (same fail-safe as editRepoMap).
499
+ function setDispatchAgent(graphHomeDir, agentId) {
500
+ try {
501
+ const file = userConfigPath(graphHomeDir);
502
+ let raw = null;
503
+ try {
504
+ raw = fs.readFileSync(file, "utf8");
505
+ } catch {
506
+ raw = null; // absent — start fresh
507
+ }
508
+ let data = {};
509
+ if (raw != null) {
510
+ try {
511
+ data = JSON.parse(raw);
512
+ } catch {
513
+ return false; // malformed — do NOT overwrite
514
+ }
515
+ if (data == null || typeof data !== "object" || Array.isArray(data)) data = {};
516
+ }
517
+ if (data.dispatch == null || typeof data.dispatch !== "object" || Array.isArray(data.dispatch)) data.dispatch = {};
518
+ const next = agentId || null;
519
+ if ((data.dispatch.agent || null) === next) return false; // unchanged — skip the write
520
+ if (next == null) delete data.dispatch.agent;
521
+ else data.dispatch.agent = next;
522
+ if (!ensureDir(graphHomeDir)) return false;
523
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
524
+ return true;
525
+ } catch {
526
+ return false;
527
+ }
528
+ }
529
+
493
530
  function appendLine(file, line) {
494
531
  try {
495
532
  fs.appendFileSync(file, line + "\n");
@@ -780,6 +817,7 @@ module.exports = {
780
817
  ensureGraphGitignore,
781
818
  registerRepo,
782
819
  forgetRepo,
820
+ setDispatchAgent,
783
821
  appendLine,
784
822
  makeLogger,
785
823
  loadGraphCached,
@@ -86,9 +86,12 @@ spor add "<2-3 sentences>" # capture a node (typed file locally; /v1/capture
86
86
  # remote (team server) only
87
87
  spor lens [<id>] # list saved views, or render one
88
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)
89
90
  spor dispatch <id>|"<task>" # run work as a background agent; in team mode its graph writes are
90
91
  # attributed "agent on behalf of you" (the machine's default agent =
91
- # dispatch.agent / SPOR_DISPATCH_AGENT). See API.md §3-§4.
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.
92
95
 
93
96
  # dual-mode (local passthrough / remote dispatch to the server)
94
97
  spor compile --query "<text>" # search → compiled neighborhood (--digest for compact)