@sporhq/spor 0.2.5 → 0.2.7
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/README.md +44 -27
- package/bin/spor.js +328 -1
- package/lib/config.js +1 -1
- package/package.json +1 -1
- package/scripts/engines/session-start.js +16 -0
- package/scripts/engines/util.js +68 -0
|
@@ -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
|
+
"version": "0.2.7",
|
|
6
6
|
"author": { "name": "losthammer" }
|
|
7
7
|
}
|
package/README.md
CHANGED
|
@@ -24,21 +24,8 @@ Requires Node 20+ and nothing else — the client is zero-dependency. To run
|
|
|
24
24
|
from a checkout instead (e.g. to hack on it), clone the repo and `npm link`
|
|
25
25
|
from its root; that symlinks the same two commands onto your PATH.
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
spor init # creates ~/.spor/nodes, git-inits it, writes .gitignore
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
`spor init` is idempotent. `spor status` then tells you the resolved mode,
|
|
34
|
-
graph, project, and (in remote mode) server health and identity — run it any
|
|
35
|
-
time you're unsure whether Spor is active or which graph you're on. (Without
|
|
36
|
-
the `spor` CLI on your PATH the equivalent is
|
|
37
|
-
`mkdir -p ~/.spor/nodes && git -C ~/.spor init && printf 'journal/\n' > ~/.spor/.gitignore`.)
|
|
38
|
-
|
|
39
|
-
Then install for your agent. One verb wires up any supported host — it
|
|
40
|
-
resolves the adapter manifest to this checkout and drops it into the host's
|
|
41
|
-
config:
|
|
27
|
+
Wire Spor into your agent. One verb resolves the adapter manifest to this
|
|
28
|
+
install and drops it into the host's config:
|
|
42
29
|
|
|
43
30
|
```bash
|
|
44
31
|
spor install claude # Claude Code (via its plugin CLI — no marketplace browsing)
|
|
@@ -50,26 +37,56 @@ spor install # no host => list the hosts detected on this machine
|
|
|
50
37
|
per-repo config. `--all` installs every detected host, `--print` is a dry run,
|
|
51
38
|
and `--server <url> --token <tok>` also points the client at a team graph in
|
|
52
39
|
the same step. Re-running is idempotent — it refreshes the path and never
|
|
53
|
-
duplicates your other hooks.
|
|
40
|
+
duplicates your other hooks. (In Claude Code you can also install by hand:
|
|
41
|
+
`/plugin marketplace add sporhq/spor` then `/plugin install spor@spor`.)
|
|
54
42
|
|
|
55
|
-
|
|
56
|
-
prefer:
|
|
43
|
+
Then onboard a repo — one command, from inside it:
|
|
57
44
|
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
/plugin install spor@spor
|
|
45
|
+
```bash
|
|
46
|
+
cd ~/my-repo && spor dispatch --backfill
|
|
61
47
|
```
|
|
62
48
|
|
|
49
|
+
That does the whole setup in one step: creates your graph home if it doesn't
|
|
50
|
+
exist yet (`~/.spor/nodes`, git-initialised), registers the repo so Spor knows
|
|
51
|
+
where it lives on this machine, makes sure Spor is enabled for it, and launches
|
|
52
|
+
the `/spor:backfill` agent in a Claude Code background session — it mines git
|
|
53
|
+
history, design docs, and issue trackers (edges first) and proposes how to
|
|
54
|
+
group your repos into projects. Watch or attach to it with `claude agents`.
|
|
55
|
+
Re-run it whenever you add a repo, or skip it entirely and just work —
|
|
56
|
+
distillation grows the graph one session at a time.
|
|
57
|
+
|
|
58
|
+
`spor status` tells you the resolved mode, graph, project, and (on a team
|
|
59
|
+
graph) server health and identity — run it any time you're unsure whether Spor
|
|
60
|
+
is active or which graph you're on. `spor init` does the graph-home setup on
|
|
61
|
+
its own if you'd rather not dispatch anything yet.
|
|
62
|
+
|
|
63
63
|
For the per-host event mapping, fidelity notes, distiller backend, and the
|
|
64
64
|
`AGENTS.md` fallback for hosts with no hook support, see
|
|
65
65
|
[adapters/](adapters/).
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
## Dispatching background agents
|
|
68
|
+
|
|
69
|
+
`spor dispatch` hands a task to Claude Code's background-agent machinery
|
|
70
|
+
(`claude --bg`) with a briefing already compiled in:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
spor dispatch "wire up token rotation in the pipeline" # free-text task, briefed
|
|
74
|
+
spor dispatch issue-86 # a node id — briefs its neighborhood
|
|
75
|
+
spor dispatch --from-queue # the top item from 'spor next'
|
|
76
|
+
spor dispatch --backfill # onboard this repo via /spor:backfill
|
|
77
|
+
spor dispatch <task> --print # dry run: show dir, prompt, argv
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
It compiles the briefing (the same two-arm compiler the `/spor:brief` skill
|
|
81
|
+
drives), prepends it to the prompt, and launches `claude --bg` **in the right
|
|
82
|
+
repo**. Which directory that is comes from a per-machine slug→path map: the
|
|
83
|
+
shared graph is path-free by design (every teammate clones to a different
|
|
84
|
+
path), so the map is local, kept in the config cascade under `dispatch.repos`
|
|
85
|
+
(`spor repos` to inspect; written to `$SPOR_HOME/config.json`). It self-learns
|
|
86
|
+
as you open sessions, so by the time you dispatch a node from another repo,
|
|
87
|
+
Spor already knows where that repo lives. Flags pass through to `claude`
|
|
88
|
+
(`--model`, `--permission-mode`, `--agent`, `--name`); `--full` embeds the whole
|
|
89
|
+
neighborhood and `--no-brief` skips the briefing.
|
|
73
90
|
|
|
74
91
|
## What your agent gets, and gives back
|
|
75
92
|
|
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
|
|
@@ -93,7 +101,10 @@ function nodeCount(nodesDir) {
|
|
|
93
101
|
}
|
|
94
102
|
}
|
|
95
103
|
|
|
96
|
-
|
|
104
|
+
// Idempotently create the local graph home (nodes/, git, .gitignore). Returns
|
|
105
|
+
// { home, nodesDir, created } and prints nothing — callers do their own UX.
|
|
106
|
+
// Shared by `spor init` and the `spor dispatch --backfill` onboarding path.
|
|
107
|
+
function ensureGraphHome(cfg) {
|
|
97
108
|
const home = cfg.graphHome();
|
|
98
109
|
const nodesDir = path.join(home, "nodes");
|
|
99
110
|
let created = false;
|
|
@@ -114,6 +125,11 @@ function cmdInit(cfg) {
|
|
|
114
125
|
/* non-fatal */
|
|
115
126
|
}
|
|
116
127
|
}
|
|
128
|
+
return { home, nodesDir, created };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function cmdInit(cfg) {
|
|
132
|
+
const { home, nodesDir, created } = ensureGraphHome(cfg);
|
|
117
133
|
out(`${created ? "Created" : "Graph already present at"} ${home}`);
|
|
118
134
|
out(` nodes: ${nodesDir} (${nodeCount(nodesDir) ?? 0} nodes)`);
|
|
119
135
|
out(` mode: ${cfg.mode()}`);
|
|
@@ -905,6 +921,312 @@ async function cmdInstall(cfg, args) {
|
|
|
905
921
|
return rc;
|
|
906
922
|
}
|
|
907
923
|
|
|
924
|
+
// --- spor dispatch: kick off a Claude Code background agent --------------
|
|
925
|
+
// (task-spor-cli-dispatch-background-agents) Compile a briefing for a task and
|
|
926
|
+
// launch `claude --bg "<prompt>"` in the correct repo. The "correct repo" comes
|
|
927
|
+
// from a per-machine slug->path map stored in the config cascade under
|
|
928
|
+
// `dispatch.repos` (read via cfg.get; written to $SPOR_HOME/config.json) — the
|
|
929
|
+
// shared graph is path-free by design (repo nodes carry slugs/fingerprints,
|
|
930
|
+
// never a local path; teammates clone to different paths), so the map MUST be
|
|
931
|
+
// local. It self-learns from session-start and from `--dir`/`spor repos`.
|
|
932
|
+
|
|
933
|
+
// Read a single frontmatter scalar from raw node markdown (regex, like the
|
|
934
|
+
// engines' parser — no YAML lib). `repo:` is the current stamp; `project:` legacy.
|
|
935
|
+
function fmField(raw, key) {
|
|
936
|
+
const m = raw.match(new RegExp(`^${key}: *(.*)$`, "m"));
|
|
937
|
+
return m ? m[1].trim() : null;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Resolve a node id to { id, raw, repo, title } or null if it doesn't exist.
|
|
941
|
+
async function resolveNode(cfg, id) {
|
|
942
|
+
let raw = "";
|
|
943
|
+
if (cfg.mode() === "remote") {
|
|
944
|
+
const r = await remote.get(cfg, `/v1/nodes/${encodeURIComponent(id)}`, { timeoutMs: 6000 });
|
|
945
|
+
if (!r.ok) return null;
|
|
946
|
+
raw = (r.json && r.json.raw) || r.text || "";
|
|
947
|
+
} else {
|
|
948
|
+
try {
|
|
949
|
+
raw = fs.readFileSync(path.join(cfg.nodesDir(), `${id}.md`), "utf8");
|
|
950
|
+
} catch {
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return { id, raw, repo: fmField(raw, "repo") || fmField(raw, "project"), title: fmField(raw, "title") || "" };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Compile a briefing: a node id -> its neighborhood; free text -> a digest.
|
|
958
|
+
// Mode-aware, reusing the primitives the /spor:brief skill drives. Default is
|
|
959
|
+
// the compact digest; `full` emits the whole neighborhood. "" = graph had
|
|
960
|
+
// nothing relevant (or the compile failed — fail-soft, dispatch still proceeds).
|
|
961
|
+
async function compileBriefing(cfg, { nodeId, query, full, project }) {
|
|
962
|
+
if (cfg.mode() === "remote") {
|
|
963
|
+
if (nodeId) {
|
|
964
|
+
const r = await remote.get(cfg, `/v1/nodes/${encodeURIComponent(nodeId)}`, { timeoutMs: 8000 });
|
|
965
|
+
return r.ok && r.json ? r.json.raw || r.text || "" : "";
|
|
966
|
+
}
|
|
967
|
+
const r = await remote.post(cfg, "/v1/digest", project ? { query, project } : { query });
|
|
968
|
+
return r.ok && r.json && r.json.found !== false ? r.json.text || "" : "";
|
|
969
|
+
}
|
|
970
|
+
const args = nodeId ? ["--root", nodeId] : ["--query", query];
|
|
971
|
+
if (!full) args.push("--digest");
|
|
972
|
+
if (project) args.push("--project", project);
|
|
973
|
+
args.push("--quiet"); // suppress the stderr stats / no-graph lines
|
|
974
|
+
const r = spawnSync(process.execPath, [path.join(ROOT, "lib", "compile.js"), ...args], { encoding: "utf8" });
|
|
975
|
+
return (r.stdout || "").trim();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// The single highest-ranked open queue item (for --from-queue). Mode-aware,
|
|
979
|
+
// fail-soft (null on any error/empty).
|
|
980
|
+
async function topQueueItem(cfg, slug) {
|
|
981
|
+
if (cfg.mode() === "remote") {
|
|
982
|
+
const q = slug ? `?project=${encodeURIComponent(slug)}&limit=1` : "?limit=1";
|
|
983
|
+
const r = await remote.get(cfg, `/v1/queue${q}`, { timeoutMs: 6000 });
|
|
984
|
+
return r.ok && r.json ? (r.json.items || [])[0] || null : null;
|
|
985
|
+
}
|
|
986
|
+
try {
|
|
987
|
+
const g = require(path.join(ROOT, "lib", "graph.js")).loadGraph(cfg.nodesDir());
|
|
988
|
+
const { rankQueue } = require(path.join(ROOT, "lib", "queue.js"));
|
|
989
|
+
const r = rankQueue(g, slug ? { project: slug, limit: 1 } : { limit: 1 });
|
|
990
|
+
return (r.items || [])[0] || null;
|
|
991
|
+
} catch {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Resolve the directory to launch in. --dir wins; else a known slug is looked up
|
|
997
|
+
// in the map; else the cwd's repo root. { dir:null } means "slug unknown here".
|
|
998
|
+
function resolveDir(cfg, { dir, slug }) {
|
|
999
|
+
if (dir) {
|
|
1000
|
+
const abs = path.resolve(dir);
|
|
1001
|
+
return { dir: abs, slug: slug || u.projectSlug(abs), source: "--dir" };
|
|
1002
|
+
}
|
|
1003
|
+
if (slug) {
|
|
1004
|
+
const p = (cfg.get("dispatch.repos", {}) || {})[slug];
|
|
1005
|
+
return p ? { dir: p, slug, source: "config" } : { dir: null, slug, source: "unknown" };
|
|
1006
|
+
}
|
|
1007
|
+
return { dir: repoRoot(), slug: safeSlug(), source: "cwd" };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Quote an argv element for the --print display only (never used to spawn).
|
|
1011
|
+
function shellQuote(s) {
|
|
1012
|
+
return /[^\w./:-]/.test(s) ? `'${String(s).replace(/'/g, "'\\''")}'` : s;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Re-enable Spor for a repo by merging { enabled: true } into its committable
|
|
1016
|
+
// .spor.json (and clearing a `mode: off`, which also disables). Used by the
|
|
1017
|
+
// --backfill onboarding to repair a repo a prior `spor disable` turned off.
|
|
1018
|
+
function enableRepoAt(dir) {
|
|
1019
|
+
const file = path.join(dir, ".spor.json");
|
|
1020
|
+
let data = {};
|
|
1021
|
+
try {
|
|
1022
|
+
data = JSON.parse(fs.readFileSync(file, "utf8")) || {};
|
|
1023
|
+
} catch {
|
|
1024
|
+
/* absent or malformed — start fresh */
|
|
1025
|
+
}
|
|
1026
|
+
data.enabled = true;
|
|
1027
|
+
if (data.mode === "off") delete data.mode;
|
|
1028
|
+
try {
|
|
1029
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
|
1030
|
+
} catch {
|
|
1031
|
+
/* non-fatal */
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// `spor dispatch --backfill` is the onboarding door (task-spor-cli-dispatch-
|
|
1036
|
+
// background-agents): set the repo up before launching its backfill agent.
|
|
1037
|
+
// Idempotent; prints what it did. The dir-registration happens in cmdDispatch
|
|
1038
|
+
// (it applies to every dispatch), this adds the init + enable steps.
|
|
1039
|
+
function onboardRepo(cfg, dir) {
|
|
1040
|
+
// Init the local graph home — but only in local mode; remote mode keeps the
|
|
1041
|
+
// graph on the server, so there is nothing to create locally.
|
|
1042
|
+
if (cfg.mode() !== "remote") {
|
|
1043
|
+
const r = ensureGraphHome(cfg);
|
|
1044
|
+
out(r.created ? `initialized graph home at ${r.home}` : `graph home ready: ${r.home}`);
|
|
1045
|
+
}
|
|
1046
|
+
// Re-enable the repo if a prior `spor disable` turned it off, so onboarding a
|
|
1047
|
+
// disabled repo actually works instead of silently launching into a no-op.
|
|
1048
|
+
if (!cfg.enabled()) {
|
|
1049
|
+
enableRepoAt(dir);
|
|
1050
|
+
out(`re-enabled Spor for ${dir}`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function cmdDispatch(cfg, args) {
|
|
1055
|
+
const dryRun = args.includes("--print") || args.includes("--dry-run");
|
|
1056
|
+
const full = args.includes("--full");
|
|
1057
|
+
const noBrief = args.includes("--no-brief");
|
|
1058
|
+
const backfill = args.includes("--backfill");
|
|
1059
|
+
const fromQueue = args.includes("--from-queue");
|
|
1060
|
+
const dirOpt = optVal(args, "dir");
|
|
1061
|
+
const model = optVal(args, "model");
|
|
1062
|
+
const permMode = optVal(args, "permission-mode");
|
|
1063
|
+
const agent = optVal(args, "agent");
|
|
1064
|
+
let nodeId = optVal(args, "node");
|
|
1065
|
+
let targetSlug = optVal(args, "slug");
|
|
1066
|
+
let name = optVal(args, "name");
|
|
1067
|
+
|
|
1068
|
+
// Positional task text: everything that isn't a flag or a flag's value.
|
|
1069
|
+
const VALUE_OPTS = new Set(["dir", "node", "slug", "model", "permission-mode", "agent", "name"]);
|
|
1070
|
+
const pos = [];
|
|
1071
|
+
for (let i = 0; i < args.length; i++) {
|
|
1072
|
+
const a = args[i];
|
|
1073
|
+
if (a.startsWith("--")) {
|
|
1074
|
+
if (VALUE_OPTS.has(a.slice(2))) i++;
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
pos.push(a);
|
|
1078
|
+
}
|
|
1079
|
+
let taskText = pos.join(" ").trim();
|
|
1080
|
+
|
|
1081
|
+
let brief = "";
|
|
1082
|
+
let instruction = "";
|
|
1083
|
+
|
|
1084
|
+
if (fromQueue) {
|
|
1085
|
+
const top = await topQueueItem(cfg, targetSlug);
|
|
1086
|
+
if (!top || !top.id) {
|
|
1087
|
+
err("queue empty — nothing to dispatch");
|
|
1088
|
+
return 1;
|
|
1089
|
+
}
|
|
1090
|
+
nodeId = top.id;
|
|
1091
|
+
targetSlug = targetSlug || top.repo || top.project || null;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (backfill) {
|
|
1095
|
+
// Onboarding a (possibly thin) repo: dispatch the skill; no briefing to compile.
|
|
1096
|
+
instruction = taskText ? `/spor:backfill\n\n${taskText}` : "/spor:backfill";
|
|
1097
|
+
name = name || "spor-backfill";
|
|
1098
|
+
} else if (!nodeId && pos.length === 1 && /^[a-z0-9]+(-[a-z0-9]+)+$/.test(pos[0])) {
|
|
1099
|
+
// Auto-detect: a single hyphenated token that resolves to a node => node mode.
|
|
1100
|
+
const maybe = await resolveNode(cfg, pos[0]);
|
|
1101
|
+
if (maybe) {
|
|
1102
|
+
nodeId = maybe.id;
|
|
1103
|
+
taskText = "";
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (!backfill && nodeId) {
|
|
1108
|
+
const node = await resolveNode(cfg, nodeId);
|
|
1109
|
+
if (!node) {
|
|
1110
|
+
err(`no such node: ${nodeId}`);
|
|
1111
|
+
return 1;
|
|
1112
|
+
}
|
|
1113
|
+
targetSlug = targetSlug || node.repo || null;
|
|
1114
|
+
if (!noBrief) brief = await compileBriefing(cfg, { nodeId, full, project: targetSlug });
|
|
1115
|
+
instruction = `Work on ${nodeId}${node.title ? ` — ${node.title}` : ""}. The compiled Spor briefing above is your standing context.${taskText ? ` ${taskText}` : ""}`;
|
|
1116
|
+
name = name || nodeId;
|
|
1117
|
+
} else if (!backfill) {
|
|
1118
|
+
if (!taskText) {
|
|
1119
|
+
err('usage: spor dispatch "<task>" | --node <id> | --from-queue | --backfill');
|
|
1120
|
+
return 1;
|
|
1121
|
+
}
|
|
1122
|
+
if (!noBrief) brief = await compileBriefing(cfg, { query: taskText, full, project: targetSlug });
|
|
1123
|
+
instruction = taskText;
|
|
1124
|
+
name = name || taskText.split(/\s+/).slice(0, 8).join(" ").slice(0, 60);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const res = resolveDir(cfg, { dir: dirOpt, slug: targetSlug });
|
|
1128
|
+
if (!res.dir) {
|
|
1129
|
+
err(`don't know where '${res.slug}' lives on this machine.`);
|
|
1130
|
+
err(` run 'spor dispatch' from inside that repo once (it self-registers), then re-run, or:`);
|
|
1131
|
+
err(` spor repos add ${res.slug} <path>`);
|
|
1132
|
+
err(` or pass --dir <path>.`);
|
|
1133
|
+
return 1;
|
|
1134
|
+
}
|
|
1135
|
+
if (!fs.existsSync(res.dir)) {
|
|
1136
|
+
err(`target dir does not exist: ${res.dir}`);
|
|
1137
|
+
return 1;
|
|
1138
|
+
}
|
|
1139
|
+
const prompt = brief
|
|
1140
|
+
? `# Spor briefing (compiled for this task — your standing context)\n\n${brief}\n\n---\n\n# Task\n\n${instruction}\n`
|
|
1141
|
+
: instruction;
|
|
1142
|
+
|
|
1143
|
+
const claudeBin = process.env.SPOR_CLAUDE_CMD || "claude";
|
|
1144
|
+
const claudeArgs = ["--bg"];
|
|
1145
|
+
if (name) claudeArgs.push("--name", name);
|
|
1146
|
+
if (model) claudeArgs.push("--model", model);
|
|
1147
|
+
if (permMode) claudeArgs.push("--permission-mode", permMode);
|
|
1148
|
+
if (agent) claudeArgs.push("--agent", agent);
|
|
1149
|
+
claudeArgs.push(prompt);
|
|
1150
|
+
|
|
1151
|
+
if (dryRun) {
|
|
1152
|
+
out(`dir: ${res.dir} (slug: ${res.slug}, via ${res.source})`);
|
|
1153
|
+
if (backfill) {
|
|
1154
|
+
const steps = [];
|
|
1155
|
+
if (cfg.mode() !== "remote") steps.push(fs.existsSync(cfg.nodesDir()) ? "graph home ready" : "init graph home");
|
|
1156
|
+
steps.push(`register ${res.slug} → ${res.dir}`);
|
|
1157
|
+
if (!cfg.enabled()) steps.push("re-enable repo (currently disabled)");
|
|
1158
|
+
out(`onboard: ${steps.join("; ")}`);
|
|
1159
|
+
}
|
|
1160
|
+
out(`brief: ${brief ? `${brief.length} bytes` : "(none — graph had nothing relevant, or --no-brief/--backfill)"}`);
|
|
1161
|
+
out(`run: ${claudeBin} ${claudeArgs.slice(0, -1).map(shellQuote).join(" ")} <prompt>`);
|
|
1162
|
+
out(`\n--- prompt ---\n${prompt}`);
|
|
1163
|
+
return 0;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Side effects (real run only — --print writes nothing). --backfill is the
|
|
1167
|
+
// onboarding door, so it sets the repo up (init + enable) first; every
|
|
1168
|
+
// dispatch self-registers the dir it resolved.
|
|
1169
|
+
if (backfill) onboardRepo(cfg, res.dir);
|
|
1170
|
+
u.registerRepo(cfg.graphHome(), res.slug, res.dir);
|
|
1171
|
+
if (backfill) out(`registered ${res.slug} → ${res.dir}; launching the backfill agent…`);
|
|
1172
|
+
|
|
1173
|
+
if (claudeBin === "claude" && !hasCmd("claude")) {
|
|
1174
|
+
err("claude CLI not on PATH — install Claude Code, then re-run (or 'spor dispatch … --print' to see the prompt).");
|
|
1175
|
+
return 1;
|
|
1176
|
+
}
|
|
1177
|
+
const r = spawnSync(claudeBin, claudeArgs, { cwd: res.dir, stdio: "inherit" });
|
|
1178
|
+
if (r.error) {
|
|
1179
|
+
err(`could not launch ${claudeBin}: ${r.error.message}`);
|
|
1180
|
+
return 1;
|
|
1181
|
+
}
|
|
1182
|
+
return r.status == null ? 1 : r.status;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// --- spor repos: inspect/manage the local slug->path map -----------------
|
|
1186
|
+
function cmdRepos(cfg, args) {
|
|
1187
|
+
const home = cfg.graphHome();
|
|
1188
|
+
const sub = args[0];
|
|
1189
|
+
if (!sub || sub === "list") {
|
|
1190
|
+
// Resolved through the config cascade (dispatch.repos), so user, global, and
|
|
1191
|
+
// any repo/env override layers compose; writes land in $SPOR_HOME/config.json.
|
|
1192
|
+
const map = cfg.get("dispatch.repos", {}) || {};
|
|
1193
|
+
const keys = Object.keys(map).sort();
|
|
1194
|
+
if (!keys.length) {
|
|
1195
|
+
out("no repos mapped yet — they self-register as you open sessions, or: spor repos add <slug> <path>");
|
|
1196
|
+
return 0;
|
|
1197
|
+
}
|
|
1198
|
+
for (const k of keys) out(`${k}\t${map[k]}`);
|
|
1199
|
+
return 0;
|
|
1200
|
+
}
|
|
1201
|
+
if (sub === "add" || sub === "set") {
|
|
1202
|
+
const slug = args[1];
|
|
1203
|
+
const p = args[2];
|
|
1204
|
+
if (!slug || !p) {
|
|
1205
|
+
err("usage: spor repos add <slug> <path>");
|
|
1206
|
+
return 1;
|
|
1207
|
+
}
|
|
1208
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
|
1209
|
+
err(`invalid slug '${slug}' — must match ^[a-z0-9][a-z0-9-]*$`);
|
|
1210
|
+
return 1;
|
|
1211
|
+
}
|
|
1212
|
+
const abs = path.resolve(p);
|
|
1213
|
+
u.registerRepo(home, slug, abs);
|
|
1214
|
+
out(`mapped ${slug} -> ${abs}`);
|
|
1215
|
+
return 0;
|
|
1216
|
+
}
|
|
1217
|
+
if (sub === "rm" || sub === "remove" || sub === "forget") {
|
|
1218
|
+
const slug = args[1];
|
|
1219
|
+
if (!slug) {
|
|
1220
|
+
err("usage: spor repos rm <slug>");
|
|
1221
|
+
return 1;
|
|
1222
|
+
}
|
|
1223
|
+
out(u.forgetRepo(home, slug) ? `forgot ${slug}` : `no mapping for ${slug}`);
|
|
1224
|
+
return 0;
|
|
1225
|
+
}
|
|
1226
|
+
err("usage: spor repos [list] | spor repos add <slug> <path> | spor repos rm <slug>");
|
|
1227
|
+
return 1;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
908
1230
|
function version() {
|
|
909
1231
|
try {
|
|
910
1232
|
return require(path.join(ROOT, "package.json")).version || "0.0.0";
|
|
@@ -967,6 +1289,11 @@ async function main() {
|
|
|
967
1289
|
case "compile":
|
|
968
1290
|
case "brief":
|
|
969
1291
|
return cmdCompile(cfg, verb, args);
|
|
1292
|
+
case "dispatch":
|
|
1293
|
+
case "bg":
|
|
1294
|
+
return await cmdDispatch(cfg, args);
|
|
1295
|
+
case "repos":
|
|
1296
|
+
return cmdRepos(cfg, args);
|
|
970
1297
|
case "validate":
|
|
971
1298
|
return passthrough("validate.js", args);
|
|
972
1299
|
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.
|
|
3
|
+
"version": "0.2.7",
|
|
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
|
// -------------------------------------------------------------------------
|
package/scripts/engines/util.js
CHANGED
|
@@ -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,
|