@sporhq/spor 0.2.5 → 0.2.6

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.2.5",
5
+ "version": "0.2.6",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/README.md CHANGED
@@ -71,6 +71,30 @@ context, and then proposes how to group your repos into projects (re-run it as
71
71
  you add repos). Or skip it and just work — distillation grows the graph one
72
72
  session at a time.
73
73
 
74
+ ## Dispatching background agents
75
+
76
+ `spor dispatch` hands a task to Claude Code's background-agent machinery
77
+ (`claude --bg`) with a briefing already compiled in:
78
+
79
+ ```bash
80
+ spor dispatch "wire up token rotation in the pipeline" # free-text task, briefed
81
+ spor dispatch issue-86 # a node id — briefs its neighborhood
82
+ spor dispatch --from-queue # the top item from 'spor next'
83
+ spor dispatch --backfill # onboard this repo via /spor:backfill
84
+ spor dispatch <task> --print # dry run: show dir, prompt, argv
85
+ ```
86
+
87
+ It compiles the briefing (the same two-arm compiler the `/spor:brief` skill
88
+ drives), prepends it to the prompt, and launches `claude --bg` **in the right
89
+ repo**. Which directory that is comes from a per-machine slug→path map: the
90
+ shared graph is path-free by design (every teammate clones to a different
91
+ path), so the map is local, kept in the config cascade under `dispatch.repos`
92
+ (`spor repos` to inspect; written to `$SPOR_HOME/config.json`). It self-learns
93
+ as you open sessions, so by the time you dispatch a node from another repo,
94
+ Spor already knows where that repo lives. Flags pass through to `claude`
95
+ (`--model`, `--permission-mode`, `--agent`, `--name`); `--full` embeds the whole
96
+ neighborhood and `--no-brief` skips the briefing.
97
+
74
98
  ## What your agent gets, and gives back
75
99
 
76
100
  The loop runs without you having to drive it:
package/bin/spor.js CHANGED
@@ -57,6 +57,14 @@ Repo scoping
57
57
  brief <id> compile a briefing for a node (alias: compile --root <id>)
58
58
  validate lint the local graph (byte-identical)
59
59
 
60
+ Dispatch (Claude Code background agents)
61
+ dispatch "<task>" compile a briefing + launch 'claude --bg' in the repo.
62
+ Also: dispatch <node-id> | --node <id> | --from-queue |
63
+ --backfill. Flags: --dir P, --full, --no-brief, --model M,
64
+ --permission-mode P, --agent A, --name N, --print
65
+ repos show the local slug->repo-dir map used to pick the
66
+ directory (repos add <slug> <path> | repos rm <slug>)
67
+
60
68
  Other
61
69
  cost [--since D] LLM spend summary from journal/llm-calls (local)
62
70
  version print version
@@ -905,6 +913,262 @@ async function cmdInstall(cfg, args) {
905
913
  return rc;
906
914
  }
907
915
 
916
+ // --- spor dispatch: kick off a Claude Code background agent --------------
917
+ // (task-spor-cli-dispatch-background-agents) Compile a briefing for a task and
918
+ // launch `claude --bg "<prompt>"` in the correct repo. The "correct repo" comes
919
+ // from a per-machine slug->path map stored in the config cascade under
920
+ // `dispatch.repos` (read via cfg.get; written to $SPOR_HOME/config.json) — the
921
+ // shared graph is path-free by design (repo nodes carry slugs/fingerprints,
922
+ // never a local path; teammates clone to different paths), so the map MUST be
923
+ // local. It self-learns from session-start and from `--dir`/`spor repos`.
924
+
925
+ // Read a single frontmatter scalar from raw node markdown (regex, like the
926
+ // engines' parser — no YAML lib). `repo:` is the current stamp; `project:` legacy.
927
+ function fmField(raw, key) {
928
+ const m = raw.match(new RegExp(`^${key}: *(.*)$`, "m"));
929
+ return m ? m[1].trim() : null;
930
+ }
931
+
932
+ // Resolve a node id to { id, raw, repo, title } or null if it doesn't exist.
933
+ async function resolveNode(cfg, id) {
934
+ let raw = "";
935
+ if (cfg.mode() === "remote") {
936
+ const r = await remote.get(cfg, `/v1/nodes/${encodeURIComponent(id)}`, { timeoutMs: 6000 });
937
+ if (!r.ok) return null;
938
+ raw = (r.json && r.json.raw) || r.text || "";
939
+ } else {
940
+ try {
941
+ raw = fs.readFileSync(path.join(cfg.nodesDir(), `${id}.md`), "utf8");
942
+ } catch {
943
+ return null;
944
+ }
945
+ }
946
+ return { id, raw, repo: fmField(raw, "repo") || fmField(raw, "project"), title: fmField(raw, "title") || "" };
947
+ }
948
+
949
+ // Compile a briefing: a node id -> its neighborhood; free text -> a digest.
950
+ // Mode-aware, reusing the primitives the /spor:brief skill drives. Default is
951
+ // the compact digest; `full` emits the whole neighborhood. "" = graph had
952
+ // nothing relevant (or the compile failed — fail-soft, dispatch still proceeds).
953
+ async function compileBriefing(cfg, { nodeId, query, full, project }) {
954
+ if (cfg.mode() === "remote") {
955
+ if (nodeId) {
956
+ const r = await remote.get(cfg, `/v1/nodes/${encodeURIComponent(nodeId)}`, { timeoutMs: 8000 });
957
+ return r.ok && r.json ? r.json.raw || r.text || "" : "";
958
+ }
959
+ const r = await remote.post(cfg, "/v1/digest", project ? { query, project } : { query });
960
+ return r.ok && r.json && r.json.found !== false ? r.json.text || "" : "";
961
+ }
962
+ const args = nodeId ? ["--root", nodeId] : ["--query", query];
963
+ if (!full) args.push("--digest");
964
+ if (project) args.push("--project", project);
965
+ args.push("--quiet"); // suppress the stderr stats / no-graph lines
966
+ const r = spawnSync(process.execPath, [path.join(ROOT, "lib", "compile.js"), ...args], { encoding: "utf8" });
967
+ return (r.stdout || "").trim();
968
+ }
969
+
970
+ // The single highest-ranked open queue item (for --from-queue). Mode-aware,
971
+ // fail-soft (null on any error/empty).
972
+ async function topQueueItem(cfg, slug) {
973
+ if (cfg.mode() === "remote") {
974
+ const q = slug ? `?project=${encodeURIComponent(slug)}&limit=1` : "?limit=1";
975
+ const r = await remote.get(cfg, `/v1/queue${q}`, { timeoutMs: 6000 });
976
+ return r.ok && r.json ? (r.json.items || [])[0] || null : null;
977
+ }
978
+ try {
979
+ const g = require(path.join(ROOT, "lib", "graph.js")).loadGraph(cfg.nodesDir());
980
+ const { rankQueue } = require(path.join(ROOT, "lib", "queue.js"));
981
+ const r = rankQueue(g, slug ? { project: slug, limit: 1 } : { limit: 1 });
982
+ return (r.items || [])[0] || null;
983
+ } catch {
984
+ return null;
985
+ }
986
+ }
987
+
988
+ // Resolve the directory to launch in. --dir wins; else a known slug is looked up
989
+ // in the map; else the cwd's repo root. { dir:null } means "slug unknown here".
990
+ function resolveDir(cfg, { dir, slug }) {
991
+ if (dir) {
992
+ const abs = path.resolve(dir);
993
+ return { dir: abs, slug: slug || u.projectSlug(abs), source: "--dir" };
994
+ }
995
+ if (slug) {
996
+ const p = (cfg.get("dispatch.repos", {}) || {})[slug];
997
+ return p ? { dir: p, slug, source: "config" } : { dir: null, slug, source: "unknown" };
998
+ }
999
+ return { dir: repoRoot(), slug: safeSlug(), source: "cwd" };
1000
+ }
1001
+
1002
+ // Quote an argv element for the --print display only (never used to spawn).
1003
+ function shellQuote(s) {
1004
+ return /[^\w./:-]/.test(s) ? `'${String(s).replace(/'/g, "'\\''")}'` : s;
1005
+ }
1006
+
1007
+ async function cmdDispatch(cfg, args) {
1008
+ const dryRun = args.includes("--print") || args.includes("--dry-run");
1009
+ const full = args.includes("--full");
1010
+ const noBrief = args.includes("--no-brief");
1011
+ const backfill = args.includes("--backfill");
1012
+ const fromQueue = args.includes("--from-queue");
1013
+ const dirOpt = optVal(args, "dir");
1014
+ const model = optVal(args, "model");
1015
+ const permMode = optVal(args, "permission-mode");
1016
+ const agent = optVal(args, "agent");
1017
+ let nodeId = optVal(args, "node");
1018
+ let targetSlug = optVal(args, "slug");
1019
+ let name = optVal(args, "name");
1020
+
1021
+ // Positional task text: everything that isn't a flag or a flag's value.
1022
+ const VALUE_OPTS = new Set(["dir", "node", "slug", "model", "permission-mode", "agent", "name"]);
1023
+ const pos = [];
1024
+ for (let i = 0; i < args.length; i++) {
1025
+ const a = args[i];
1026
+ if (a.startsWith("--")) {
1027
+ if (VALUE_OPTS.has(a.slice(2))) i++;
1028
+ continue;
1029
+ }
1030
+ pos.push(a);
1031
+ }
1032
+ let taskText = pos.join(" ").trim();
1033
+
1034
+ let brief = "";
1035
+ let instruction = "";
1036
+
1037
+ if (fromQueue) {
1038
+ const top = await topQueueItem(cfg, targetSlug);
1039
+ if (!top || !top.id) {
1040
+ err("queue empty — nothing to dispatch");
1041
+ return 1;
1042
+ }
1043
+ nodeId = top.id;
1044
+ targetSlug = targetSlug || top.repo || top.project || null;
1045
+ }
1046
+
1047
+ if (backfill) {
1048
+ // Onboarding a (possibly thin) repo: dispatch the skill; no briefing to compile.
1049
+ instruction = taskText ? `/spor:backfill\n\n${taskText}` : "/spor:backfill";
1050
+ name = name || "spor-backfill";
1051
+ } else if (!nodeId && pos.length === 1 && /^[a-z0-9]+(-[a-z0-9]+)+$/.test(pos[0])) {
1052
+ // Auto-detect: a single hyphenated token that resolves to a node => node mode.
1053
+ const maybe = await resolveNode(cfg, pos[0]);
1054
+ if (maybe) {
1055
+ nodeId = maybe.id;
1056
+ taskText = "";
1057
+ }
1058
+ }
1059
+
1060
+ if (!backfill && nodeId) {
1061
+ const node = await resolveNode(cfg, nodeId);
1062
+ if (!node) {
1063
+ err(`no such node: ${nodeId}`);
1064
+ return 1;
1065
+ }
1066
+ targetSlug = targetSlug || node.repo || null;
1067
+ if (!noBrief) brief = await compileBriefing(cfg, { nodeId, full, project: targetSlug });
1068
+ instruction = `Work on ${nodeId}${node.title ? ` — ${node.title}` : ""}. The compiled Spor briefing above is your standing context.${taskText ? ` ${taskText}` : ""}`;
1069
+ name = name || nodeId;
1070
+ } else if (!backfill) {
1071
+ if (!taskText) {
1072
+ err('usage: spor dispatch "<task>" | --node <id> | --from-queue | --backfill');
1073
+ return 1;
1074
+ }
1075
+ if (!noBrief) brief = await compileBriefing(cfg, { query: taskText, full, project: targetSlug });
1076
+ instruction = taskText;
1077
+ name = name || taskText.split(/\s+/).slice(0, 8).join(" ").slice(0, 60);
1078
+ }
1079
+
1080
+ const res = resolveDir(cfg, { dir: dirOpt, slug: targetSlug });
1081
+ if (!res.dir) {
1082
+ err(`don't know where '${res.slug}' lives on this machine.`);
1083
+ err(` run 'spor dispatch' from inside that repo once (it self-registers), then re-run, or:`);
1084
+ err(` spor repos add ${res.slug} <path>`);
1085
+ err(` or pass --dir <path>.`);
1086
+ return 1;
1087
+ }
1088
+ if (!fs.existsSync(res.dir)) {
1089
+ err(`target dir does not exist: ${res.dir}`);
1090
+ return 1;
1091
+ }
1092
+ // Learn the mapping from this CLI use too.
1093
+ u.registerRepo(cfg.graphHome(), res.slug, res.dir);
1094
+
1095
+ const prompt = brief
1096
+ ? `# Spor briefing (compiled for this task — your standing context)\n\n${brief}\n\n---\n\n# Task\n\n${instruction}\n`
1097
+ : instruction;
1098
+
1099
+ const claudeBin = process.env.SPOR_CLAUDE_CMD || "claude";
1100
+ const claudeArgs = ["--bg"];
1101
+ if (name) claudeArgs.push("--name", name);
1102
+ if (model) claudeArgs.push("--model", model);
1103
+ if (permMode) claudeArgs.push("--permission-mode", permMode);
1104
+ if (agent) claudeArgs.push("--agent", agent);
1105
+ claudeArgs.push(prompt);
1106
+
1107
+ if (dryRun) {
1108
+ out(`dir: ${res.dir} (slug: ${res.slug}, via ${res.source})`);
1109
+ out(`brief: ${brief ? `${brief.length} bytes` : "(none — graph had nothing relevant, or --no-brief/--backfill)"}`);
1110
+ out(`run: ${claudeBin} ${claudeArgs.slice(0, -1).map(shellQuote).join(" ")} <prompt>`);
1111
+ out(`\n--- prompt ---\n${prompt}`);
1112
+ return 0;
1113
+ }
1114
+
1115
+ if (claudeBin === "claude" && !hasCmd("claude")) {
1116
+ err("claude CLI not on PATH — install Claude Code, then re-run (or 'spor dispatch … --print' to see the prompt).");
1117
+ return 1;
1118
+ }
1119
+ const r = spawnSync(claudeBin, claudeArgs, { cwd: res.dir, stdio: "inherit" });
1120
+ if (r.error) {
1121
+ err(`could not launch ${claudeBin}: ${r.error.message}`);
1122
+ return 1;
1123
+ }
1124
+ return r.status == null ? 1 : r.status;
1125
+ }
1126
+
1127
+ // --- spor repos: inspect/manage the local slug->path map -----------------
1128
+ function cmdRepos(cfg, args) {
1129
+ const home = cfg.graphHome();
1130
+ const sub = args[0];
1131
+ if (!sub || sub === "list") {
1132
+ // Resolved through the config cascade (dispatch.repos), so user, global, and
1133
+ // any repo/env override layers compose; writes land in $SPOR_HOME/config.json.
1134
+ const map = cfg.get("dispatch.repos", {}) || {};
1135
+ const keys = Object.keys(map).sort();
1136
+ if (!keys.length) {
1137
+ out("no repos mapped yet — they self-register as you open sessions, or: spor repos add <slug> <path>");
1138
+ return 0;
1139
+ }
1140
+ for (const k of keys) out(`${k}\t${map[k]}`);
1141
+ return 0;
1142
+ }
1143
+ if (sub === "add" || sub === "set") {
1144
+ const slug = args[1];
1145
+ const p = args[2];
1146
+ if (!slug || !p) {
1147
+ err("usage: spor repos add <slug> <path>");
1148
+ return 1;
1149
+ }
1150
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
1151
+ err(`invalid slug '${slug}' — must match ^[a-z0-9][a-z0-9-]*$`);
1152
+ return 1;
1153
+ }
1154
+ const abs = path.resolve(p);
1155
+ u.registerRepo(home, slug, abs);
1156
+ out(`mapped ${slug} -> ${abs}`);
1157
+ return 0;
1158
+ }
1159
+ if (sub === "rm" || sub === "remove" || sub === "forget") {
1160
+ const slug = args[1];
1161
+ if (!slug) {
1162
+ err("usage: spor repos rm <slug>");
1163
+ return 1;
1164
+ }
1165
+ out(u.forgetRepo(home, slug) ? `forgot ${slug}` : `no mapping for ${slug}`);
1166
+ return 0;
1167
+ }
1168
+ err("usage: spor repos [list] | spor repos add <slug> <path> | spor repos rm <slug>");
1169
+ return 1;
1170
+ }
1171
+
908
1172
  function version() {
909
1173
  try {
910
1174
  return require(path.join(ROOT, "package.json")).version || "0.0.0";
@@ -967,6 +1231,11 @@ async function main() {
967
1231
  case "compile":
968
1232
  case "brief":
969
1233
  return cmdCompile(cfg, verb, args);
1234
+ case "dispatch":
1235
+ case "bg":
1236
+ return await cmdDispatch(cfg, args);
1237
+ case "repos":
1238
+ return cmdRepos(cfg, args);
970
1239
  case "validate":
971
1240
  return passthrough("validate.js", args);
972
1241
  case "cost":
package/lib/config.js CHANGED
@@ -46,7 +46,7 @@ const REPO_FORBIDDEN_KEYS = ["token"];
46
46
  // warning so a silently-ignored setting is visible rather than mysterious.
47
47
  const KNOWN_KEYS = new Set([
48
48
  "mode", "server", "token", "home", "nodes", "enabled",
49
- "search", "distill", "nudge", "inferCommits",
49
+ "search", "distill", "nudge", "inferCommits", "dispatch",
50
50
  ]);
51
51
 
52
52
  // Map of env var (sans SPOR_/SUBSTRATE_ prefix) -> config key path. Only vars
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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",
@@ -100,6 +100,22 @@ async function sessionStart(input) {
100
100
  /* best effort */
101
101
  }
102
102
 
103
+ // Learn where this slug lives on THIS machine (slug -> checkout path), so
104
+ // `spor dispatch` can later launch `claude --bg` in the right repo without a
105
+ // path map in the shared graph (paths differ per teammate; repo nodes carry
106
+ // slugs/fingerprints, never a local path). inferenceRoot() is the same repo
107
+ // root projectSlug() derives the slug from, so path and slug stay consistent
108
+ // and a linked worktree resolves to its main checkout. Pure side effect: never
109
+ // alters this run's output; fail-open (registerRepo no-ops on a non-canonical
110
+ // slug or unchanged value). (task-spor-cli-dispatch-background-agents)
111
+ try {
112
+ if (cwd && fs.existsSync(cwd)) {
113
+ u.registerRepo(graph, slug, u.inferenceRoot(cwd) || cwd);
114
+ }
115
+ } catch {
116
+ /* best effort */
117
+ }
118
+
103
119
  // -------------------------------------------------------------------------
104
120
  // REMOTE MODE
105
121
  // -------------------------------------------------------------------------
@@ -331,6 +331,71 @@ function ensureDir(dir) {
331
331
  }
332
332
  }
333
333
 
334
+ // --- local repo map (slug -> checkout path) -------------------------------
335
+ // Which directory a project slug lives in on THIS machine. Per-machine and
336
+ // machine-specific: it is NEVER in the shared graph (every teammate clones to a
337
+ // different path — repo nodes carry slugs/fingerprints, never a local path).
338
+ // It lives in the client config cascade under `dispatch.repos`
339
+ // (dec-spor-client-config-cascade), so it composes with the env/global/repo
340
+ // override layers and is READ via Config.get('dispatch.repos'). Writes target
341
+ // the USER config ($SPOR_HOME/config.json) — the same machine-local,
342
+ // never-committed file that holds server/token — so they never land in a
343
+ // committable repo .spor.json. Learned passively by session-start and written
344
+ // explicitly by `spor repos`/`spor dispatch`; fail-open throughout.
345
+ function userConfigPath(graphHomeDir) {
346
+ return path.join(graphHomeDir, "config.json");
347
+ }
348
+ // Read-modify-write $SPOR_HOME/config.json, applying `mutate(repos)` to the
349
+ // nested dispatch.repos object. Preserves every other key. Returns true only
350
+ // when it actually wrote. Refuses to clobber a present-but-malformed config
351
+ // (returns false) so a syntax error never costs the user their settings.
352
+ function editRepoMap(graphHomeDir, mutate) {
353
+ try {
354
+ const file = userConfigPath(graphHomeDir);
355
+ let raw = null;
356
+ try {
357
+ raw = fs.readFileSync(file, "utf8");
358
+ } catch {
359
+ raw = null; // absent — start fresh
360
+ }
361
+ let data = {};
362
+ if (raw != null) {
363
+ try {
364
+ data = JSON.parse(raw);
365
+ } catch {
366
+ return false; // malformed — do NOT overwrite
367
+ }
368
+ if (data == null || typeof data !== "object" || Array.isArray(data)) data = {};
369
+ }
370
+ if (data.dispatch == null || typeof data.dispatch !== "object" || Array.isArray(data.dispatch)) data.dispatch = {};
371
+ const d = data.dispatch;
372
+ if (d.repos == null || typeof d.repos !== "object" || Array.isArray(d.repos)) d.repos = {};
373
+ if (!mutate(d.repos)) return false; // unchanged — skip the write
374
+ if (!ensureDir(graphHomeDir)) return false;
375
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
376
+ return true;
377
+ } catch {
378
+ return false;
379
+ }
380
+ }
381
+ // Record slug -> dir (last-writer-wins, so a re-clone or worktree updates it).
382
+ // No-op when unchanged, to avoid rewriting config.json on every session.
383
+ function registerRepo(graphHomeDir, slug, dir) {
384
+ if (!slug || !dir || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) return false;
385
+ return editRepoMap(graphHomeDir, (repos) => {
386
+ if (repos[slug] === dir) return false;
387
+ repos[slug] = dir;
388
+ return true;
389
+ });
390
+ }
391
+ function forgetRepo(graphHomeDir, slug) {
392
+ return editRepoMap(graphHomeDir, (repos) => {
393
+ if (!(slug in repos)) return false;
394
+ delete repos[slug];
395
+ return true;
396
+ });
397
+ }
398
+
334
399
  function appendLine(file, line) {
335
400
  try {
336
401
  fs.appendFileSync(file, line + "\n");
@@ -602,11 +667,14 @@ module.exports = {
602
667
  byteTail,
603
668
  wordCount,
604
669
  stripTrailingNewlines,
670
+ inferenceRoot,
605
671
  projectSlug,
606
672
  projectGrouping,
607
673
  repoFingerprints,
608
674
  git,
609
675
  ensureDir,
676
+ registerRepo,
677
+ forgetRepo,
610
678
  appendLine,
611
679
  makeLogger,
612
680
  loadGraphCached,