@sporhq/spor 0.2.3 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +34 -10
- package/bin/spor.js +269 -0
- package/lib/config.js +1 -1
- package/package.json +1 -1
- package/scripts/engines/session-start.js +23 -2
- package/scripts/engines/util.js +68 -0
- package/skills/backfill/SKILL.md +129 -0
- package/skills/brief/SKILL.md +7 -0
- package/skills/correct/SKILL.md +7 -0
- package/skills/defer/SKILL.md +7 -0
- package/skills/next/SKILL.md +27 -5
|
@@ -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.6",
|
|
6
6
|
"author": { "name": "losthammer" }
|
|
7
7
|
}
|
package/README.md
CHANGED
|
@@ -64,13 +64,36 @@ 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
|
-
To start with a populated graph,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
To start with a populated graph, run `/spor:backfill` — the onboarding door. It
|
|
68
|
+
dispatches the heavy mining (git history, design docs, issue trackers, edges
|
|
69
|
+
first) to the bundled `spor-backfill` **subagent**, which runs in its own
|
|
70
|
+
context, and then proposes how to group your repos into projects (re-run it as
|
|
71
|
+
you add repos). Or skip it and just work — distillation grows the graph one
|
|
72
|
+
session at a time.
|
|
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.
|
|
74
97
|
|
|
75
98
|
## What your agent gets, and gives back
|
|
76
99
|
|
|
@@ -91,9 +114,10 @@ The loop runs without you having to drive it:
|
|
|
91
114
|
You can also ask for any of this directly: an on-demand briefing for a task,
|
|
92
115
|
a correction when a briefing was wrong, a capture of work you're deferring,
|
|
93
116
|
and a ranked queue of what to do next. In Claude Code these surface as
|
|
94
|
-
`/spor:brief`, `/spor:correct`, `/spor:defer`, and `/spor:next
|
|
95
|
-
|
|
96
|
-
|
|
117
|
+
`/spor:brief`, `/spor:correct`, `/spor:defer`, and `/spor:next`, plus
|
|
118
|
+
`/spor:backfill` to bootstrap/extend the graph and organize repos into projects.
|
|
119
|
+
(`/spor:backfill` is the discoverable door; the heavy git-history mining still
|
|
120
|
+
runs in the `spor-backfill` subagent it dispatches.)
|
|
97
121
|
|
|
98
122
|
Corrections are durable. When a briefing includes something stale or misses
|
|
99
123
|
something it should have known, you record the correction once, and every
|
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.
|
|
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
|
// -------------------------------------------------------------------------
|
|
@@ -160,7 +176,11 @@ async function sessionStart(input) {
|
|
|
160
176
|
const item = (q.items || [])[0];
|
|
161
177
|
if (item && item.id) {
|
|
162
178
|
const why = item.why || "";
|
|
163
|
-
|
|
179
|
+
// Emitted phrasing is deliberately plain ("next up:", not the internal
|
|
180
|
+
// "open front" coinage) so it doesn't prime the agent to parrot jargon
|
|
181
|
+
// at the human (issue-cc-skill-queue-jargon-mode-theater). The concept
|
|
182
|
+
// is still the open front; only the user-facing words changed.
|
|
183
|
+
oline = `\nnext up: ${item.id} — ${item.title || ""}${why ? ` (${why})` : ""}`;
|
|
164
184
|
if ((item.suggest || "do") === "close") oline += " — the queue suggests CLOSING it, not doing it";
|
|
165
185
|
oline += ". Full queue: /spor:next.";
|
|
166
186
|
}
|
|
@@ -328,7 +348,8 @@ ${body}`;
|
|
|
328
348
|
const r = rankQueue(g, { project: slug, limit: 1 });
|
|
329
349
|
const item = (r.items || [])[0];
|
|
330
350
|
if (item && item.id) {
|
|
331
|
-
|
|
351
|
+
// Plain user-facing phrasing — mirror remote mode's "next up:" line.
|
|
352
|
+
oline = `\nnext up: ${item.id} — ${item.title || ""}${item.why ? ` (${item.why})` : ""}`;
|
|
332
353
|
if (item.suggest === "close") oline += " — the queue suggests CLOSING it, not doing it";
|
|
333
354
|
oline += ". Full queue: /spor:next.";
|
|
334
355
|
}
|
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,
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: backfill
|
|
3
|
+
description: Bootstrap or extend a project's Spor graph and organize its repos into projects. Use to backfill a repo's history into the graph, to onboard a newly cloned repo, or to group repos ("organize my repos", "set up / pick a project for this repo", "what project does this repo belong to", "group these repos under a project"). Proposes project groupings from the repos already in the graph and writes nothing without confirmation.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Backfill & organize
|
|
7
|
+
|
|
8
|
+
Two onboarding jobs behind one door: get a repo's history INTO the graph, and
|
|
9
|
+
organize the repos that are in it into `type: project` groupings. Both are
|
|
10
|
+
re-runnable — run this again whenever you add a repo.
|
|
11
|
+
|
|
12
|
+
## 1. Populate the graph (backfill), if the repo is thin
|
|
13
|
+
|
|
14
|
+
If the current repo has little or no graph content yet, offer to backfill it:
|
|
15
|
+
spawn the **spor-backfill subagent** (Task tool, `subagent_type:
|
|
16
|
+
spor:spor-backfill`) pointed at this repo — it mines git history, design docs,
|
|
17
|
+
and issue trackers into typed nodes, edges first. Skip this when the repo is
|
|
18
|
+
already well represented (the enumeration in step 2 tells you what is present).
|
|
19
|
+
The heavy mining always runs in the subagent, never inline in this session.
|
|
20
|
+
|
|
21
|
+
## 2. Suggest project groupings
|
|
22
|
+
|
|
23
|
+
A repo's home is a `type: project` grouping it sits under via a `grouped-under`
|
|
24
|
+
edge (dec-cc-repo-project-membership-edge); project-scoped reads union every
|
|
25
|
+
repo grouped-under one project. A freshly onboarded repo starts **ungrouped**,
|
|
26
|
+
which is valid — repo-scoped reads work on its slug. This step proposes the
|
|
27
|
+
single home for confirmation; it is the whole point of running this again as
|
|
28
|
+
repos accumulate.
|
|
29
|
+
|
|
30
|
+
### a. Enumerate what exists
|
|
31
|
+
|
|
32
|
+
Gather every `type: repo` and `type: project` node, plus each repo's `slugs`,
|
|
33
|
+
`fingerprints`, and current `grouped-under` edge (if any):
|
|
34
|
+
|
|
35
|
+
- **With the Spor MCP tools** (Cowork, or Claude Code with the connector):
|
|
36
|
+
call `render_lens` with no `lens_id` to list the saved lenses, render the
|
|
37
|
+
project-breakdown one to see existing projects and their members, and
|
|
38
|
+
`query_graph` for repo nodes; `get_node` each repo to read its `fingerprints`.
|
|
39
|
+
- **Local mode** (`SPOR_SERVER` unset): scan the graph home directly —
|
|
40
|
+
```bash
|
|
41
|
+
SPOR_HOME="${SPOR_HOME:-$HOME/.spor}"; [ -d "$SPOR_HOME/nodes" ] || SPOR_HOME="$HOME/.substrate"
|
|
42
|
+
grep -lE '^type: (repo|project)$' "$SPOR_HOME"/nodes/*.md
|
|
43
|
+
```
|
|
44
|
+
Read each match: repo nodes carry `slugs:` and `fingerprints:` and (if homed)
|
|
45
|
+
a `grouped-under` edge; project nodes are the groupings themselves.
|
|
46
|
+
|
|
47
|
+
The repos with no `grouped-under` edge are the ones to home.
|
|
48
|
+
|
|
49
|
+
**Bridge older backfills.** A repo backfilled before identity-node
|
|
50
|
+
auto-registration (client ≤ 0.2.2) has `repo:`/`project:` stamps on its work
|
|
51
|
+
nodes but no `type: repo` node — and a home edge has nothing to attach to
|
|
52
|
+
without one. So before grouping, find the distinct stamp slugs that have no
|
|
53
|
+
matching `type: repo` node (no `repo-<slug>` id and not in any `slugs:`
|
|
54
|
+
register) and register an ungrouped identity node for each:
|
|
55
|
+
|
|
56
|
+
- **Local:** write `$SPOR_HOME/nodes/repo-<slug>.md` — `type: repo`,
|
|
57
|
+
`title: <slug>`, `slugs: [<slug>]`, today's `date`, no edges — then validate
|
|
58
|
+
(plugin root resolved as in step d).
|
|
59
|
+
- **Spor MCP tools:** `put_node` the same `type: repo` node.
|
|
60
|
+
|
|
61
|
+
Register them with **slugs only** — fingerprints accrue automatically when a
|
|
62
|
+
session next runs in each repo (session-start records them). The repo you are
|
|
63
|
+
currently in usually already has its node, since session-start registers it on
|
|
64
|
+
first sight; this step exists so a single `/spor:backfill` run can surface and
|
|
65
|
+
home *all* of a user's already-backfilled repos, not just the current one.
|
|
66
|
+
Announce which identity nodes you registered. (This is mechanical — the slugs
|
|
67
|
+
demonstrably exist in the graph — so it does not need the per-grouping
|
|
68
|
+
confirmation that step c does.)
|
|
69
|
+
|
|
70
|
+
### b. Group the ungrouped repos by signal
|
|
71
|
+
|
|
72
|
+
For each ungrouped repo, find its best home. Signals, strongest first:
|
|
73
|
+
|
|
74
|
+
1. **Git remote org** — `fingerprints: [remote:github.com/<org>/<repo>]`. Repos
|
|
75
|
+
sharing an org almost always belong to one product (`sporhq/spor` +
|
|
76
|
+
`sporhq/spor-server` → one project). This is the primary signal.
|
|
77
|
+
2. **Shared name stem** — `acme-web`, `acme-api`, `acme-mobile` → `acme`.
|
|
78
|
+
3. **Cross-repo edges** — a repo whose nodes carry `derived-from`/`blocks`/
|
|
79
|
+
`relates-to` edges into another repo's nodes belongs with that repo.
|
|
80
|
+
4. Shared people (contributors/stewards), then — weakest, last resort — topic
|
|
81
|
+
similarity across the repos' node text.
|
|
82
|
+
|
|
83
|
+
### c. Propose — never auto-write
|
|
84
|
+
|
|
85
|
+
Present each suggestion with its evidence and let the user confirm before any
|
|
86
|
+
write:
|
|
87
|
+
|
|
88
|
+
> - Group **repo-acme-api** under **proj-acme** (existing, ⊇ {repo-acme-web}) —
|
|
89
|
+
> shared org `github.com/acme`, shared stem `acme`. [extend]
|
|
90
|
+
> - Create **proj-acme-platform** ⊇ {repo-acme-api, repo-acme-jobs} — shared
|
|
91
|
+
> org + 3 cross-repo edges. [new]
|
|
92
|
+
|
|
93
|
+
Rules, from the two-layer identity model (dec-cc-repo-project-two-layer-identity):
|
|
94
|
+
|
|
95
|
+
- **One home per repo.** Never propose a repo into two projects — suggestions
|
|
96
|
+
are mutually exclusive per repo.
|
|
97
|
+
- **Co-ownership is banned.** A repo genuinely shared by two products gets its
|
|
98
|
+
OWN grouping, not membership in both; cross-cutting work stays at the work
|
|
99
|
+
layer as edges, not as duplicated groupings.
|
|
100
|
+
- **Ungrouped is a fine outcome.** If there is no real signal, leave the repo
|
|
101
|
+
ungrouped and say so — don't invent a singleton project to fill the slot
|
|
102
|
+
unless the user asks for one.
|
|
103
|
+
- Prefer **extending** an existing project over creating a near-duplicate.
|
|
104
|
+
|
|
105
|
+
### d. Write the confirmed homes
|
|
106
|
+
|
|
107
|
+
A grouping node's id is `proj-<stem>` (the `proj-` grouping prefix); it owns no
|
|
108
|
+
slugs or fingerprints — those live on the repo nodes. The home is a
|
|
109
|
+
`grouped-under` edge written ON the repo node, pointing TO the project.
|
|
110
|
+
|
|
111
|
+
- **Spor MCP tools:** `put_node` the new `type: project` node (skip if it
|
|
112
|
+
exists), then for each member repo `add_edge {id: "repo-<slug>", type:
|
|
113
|
+
"grouped-under", to: "proj-<stem>"}`. To RE-HOME a repo later, remove the old
|
|
114
|
+
`grouped-under` edge and add the new one — still exactly one home.
|
|
115
|
+
- **Local mode:** create `$SPOR_HOME/nodes/proj-<stem>.md` (`type: project`,
|
|
116
|
+
`title`, `summary`, today's `date`), and add a
|
|
117
|
+
`- {type: grouped-under, to: proj-<stem>}` line under each member repo node's
|
|
118
|
+
`edges:`. Then validate — resolve the plugin root the session-start hook
|
|
119
|
+
cached (issue-cc-skill-plugin-root-unsubstituted):
|
|
120
|
+
```bash
|
|
121
|
+
SPOR_ROOT="$(cat "${SPOR_HOME:-$HOME/.spor}/cache/plugin-root" 2>/dev/null \
|
|
122
|
+
|| cat "$HOME/.substrate/cache/plugin-root" 2>/dev/null)"
|
|
123
|
+
SPOR_ROOT="${SPOR_ROOT:-$CLAUDE_PLUGIN_ROOT}"
|
|
124
|
+
node "$SPOR_ROOT/lib/validate.js"
|
|
125
|
+
```
|
|
126
|
+
Fix anything it flags, and commit the graph repo if it is one.
|
|
127
|
+
|
|
128
|
+
Report what you grouped, and — just as important — what you left ungrouped and
|
|
129
|
+
why.
|
package/skills/brief/SKILL.md
CHANGED
|
@@ -9,6 +9,13 @@ You are the distiller stage of the Spor context compiler. The traversal
|
|
|
9
9
|
stage is mechanical; your job is to turn its neighborhood document into a
|
|
10
10
|
briefing an agent (or human) can act on without reading anything else.
|
|
11
11
|
|
|
12
|
+
**Resolve mode silently.** The Spor status line injected at session start tells
|
|
13
|
+
you which mode you're in (`team graph: …` = remote, `A Spor knowledge graph is
|
|
14
|
+
active: …` = local); use it, or test `[ -n "$SPOR_SERVER" ]` once if it isn't in
|
|
15
|
+
context. Don't echo `SPOR_SERVER`/`SPOR_TOKEN`/`SPOR_HOME` or announce the mode
|
|
16
|
+
to the user unless they ask, and run the local-mode resolution below without
|
|
17
|
+
echoing `$SPOR_ROOT`.
|
|
18
|
+
|
|
12
19
|
Steps:
|
|
13
20
|
|
|
14
21
|
1. Run the traversal. `$ARGUMENTS` is either a node id (e.g. `issue-86`) or a
|
package/skills/correct/SKILL.md
CHANGED
|
@@ -8,6 +8,13 @@ description: Record a standing correction to a Spor briefing (pin/exclude nodes,
|
|
|
8
8
|
Corrections are nodes. They persist in the graph and are applied at every
|
|
9
9
|
future compile of their target — a context fix made once applies forever.
|
|
10
10
|
|
|
11
|
+
**Resolve mode silently.** The Spor status line injected at session start tells
|
|
12
|
+
you which mode you're in (`team graph: …` = remote, `A Spor knowledge graph is
|
|
13
|
+
active: …` = local); use it, or test `[ -n "$SPOR_SERVER" ]` once if it isn't in
|
|
14
|
+
context. Don't echo `SPOR_SERVER`/`SPOR_TOKEN`/`SPOR_HOME` or announce the mode
|
|
15
|
+
to the user unless they ask, and run the local-mode resolution below without
|
|
16
|
+
echoing `$SPOR_ROOT`.
|
|
17
|
+
|
|
11
18
|
## Remote mode (team graph) — when `SPOR_SERVER` is set
|
|
12
19
|
|
|
13
20
|
(Env vars here are the `SPOR_*` family; the legacy `SUBSTRATE_*` names are
|
package/skills/defer/SKILL.md
CHANGED
|
@@ -14,6 +14,13 @@ Write 2-3 standalone sentences: WHAT the work is and WHY it was deferred
|
|
|
14
14
|
concrete names (files, endpoints, node ids). Do not pick node types or ids —
|
|
15
15
|
the server's ingestion model does that against the live schema registry.
|
|
16
16
|
|
|
17
|
+
**Resolve mode silently.** The Spor status line injected at session start tells
|
|
18
|
+
you which mode you're in (`team graph: …` = remote, `A Spor knowledge graph is
|
|
19
|
+
active: …` = local); use it, or test `[ -n "$SPOR_SERVER" ]` once if it isn't in
|
|
20
|
+
context. Don't echo `SPOR_SERVER`/`SPOR_TOKEN`/`SPOR_HOME` or announce the mode
|
|
21
|
+
to the user unless they ask, and run the local-mode resolution below without
|
|
22
|
+
echoing `$SPOR_ROOT`.
|
|
23
|
+
|
|
17
24
|
## Remote mode (team graph) — when `SPOR_SERVER` is set
|
|
18
25
|
|
|
19
26
|
(Env vars here are the `SPOR_*` family; the legacy `SUBSTRATE_*` names are
|
package/skills/next/SKILL.md
CHANGED
|
@@ -11,6 +11,16 @@ unprocessed captures, org-defined types) ranked by an advisory blend — what th
|
|
|
11
11
|
their neighborhood, age — plus any human-set `priority:`. High staleness
|
|
12
12
|
(anchors superseded or gone) flips the suggestion to **close**, not do.
|
|
13
13
|
|
|
14
|
+
## Mode — resolve it silently, never announce it
|
|
15
|
+
|
|
16
|
+
You already know your mode: the Spor status line injected at session start says
|
|
17
|
+
either `team graph: …` (remote — a `SPOR_SERVER` is set) or `A Spor knowledge
|
|
18
|
+
graph is active: …` (local). Use that; if it isn't in context, test
|
|
19
|
+
`[ -n "$SPOR_SERVER" ]` once. Either way, do **not** echo
|
|
20
|
+
`SPOR_SERVER`/`SPOR_TOKEN`/`SPOR_HOME`, and do **not** tell the user which mode
|
|
21
|
+
is running unless they ask — it's plumbing they don't need. Run the commands
|
|
22
|
+
below quietly (no `echo` of `$SPOR_ROOT` or the slug).
|
|
23
|
+
|
|
14
24
|
## Remote mode (team graph) — when `SPOR_SERVER` is set
|
|
15
25
|
|
|
16
26
|
(Env vars here are the `SPOR_*` family; the legacy `SUBSTRATE_*` names are
|
|
@@ -42,18 +52,30 @@ plugin root from the path the session-start hook cached
|
|
|
42
52
|
SPOR_ROOT="$(cat "${SPOR_HOME:-$HOME/.spor}/cache/plugin-root" 2>/dev/null \
|
|
43
53
|
|| cat "$HOME/.substrate/cache/plugin-root" 2>/dev/null)"
|
|
44
54
|
SPOR_ROOT="${SPOR_ROOT:-$CLAUDE_PLUGIN_ROOT}"
|
|
45
|
-
node "$SPOR_ROOT/lib/queue.js"
|
|
55
|
+
node "$SPOR_ROOT/lib/queue.js" --json # or --project <slug>, --limit <n>
|
|
46
56
|
```
|
|
47
57
|
|
|
58
|
+
Use `--json` and compose the human view from it — don't show the bare CLI
|
|
59
|
+
listing, whose leading `[<score>]` is an internal ranking number, not something
|
|
60
|
+
to surface (see Presenting).
|
|
61
|
+
|
|
48
62
|
(No server means no activity feed, so heat is 0 locally; the other signals
|
|
49
63
|
are identical.)
|
|
50
64
|
|
|
51
65
|
## Presenting and acting
|
|
52
66
|
|
|
53
|
-
|
|
54
|
-
the
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
Present in plain language — you are talking to a human who may be new to Spor.
|
|
68
|
+
Lead with the top one or two items to pick up and a short, plain reason each is
|
|
69
|
+
there ("the oldest still-open piece of work", "it blocks three other tasks",
|
|
70
|
+
"nothing's touched it in months"). **Translate** the signals behind each `why`
|
|
71
|
+
line into ordinary words; do **not** surface the raw `score` or internal
|
|
72
|
+
coinages — *open front*, *heat*, *staleness*, *front* are ranking internals, not
|
|
73
|
+
terms a newcomer knows. Honor `suggest: close` by framing the item as likely
|
|
74
|
+
done/abandonable rather than work to start. The ranking is advisory; the human
|
|
75
|
+
picks. If the result carries `muted` or `dormant` counts, mention them in
|
|
76
|
+
passing (hidden by the viewer's `queue_mute` / parked by a `wake:` date — never
|
|
77
|
+
silently dropped). Don't open with a glossary or re-explain Spor on every run —
|
|
78
|
+
one plain sentence of "why this is first" is enough. Then:
|
|
57
79
|
|
|
58
80
|
1. **Item picked to DO** → start pre-briefed: run a full root compile for it
|
|
59
81
|
(/spor:brief `<item-id>`, or locally with `$SPOR_ROOT` resolved as above:
|