@sporhq/spor 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/spor.js +118 -5
- package/lib/config.js +1 -0
- package/lib/graph.js +3 -0
- package/lib/kernel/graph.js +20 -0
- package/lib/query.js +206 -0
- package/lib/queue.js +60 -3
- package/package.json +1 -1
- package/skills/brief/SKILL.md +15 -3
- package/skills/spor/SKILL.md +25 -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.
|
|
5
|
+
"version": "0.5.0",
|
|
6
6
|
"author": { "name": "losthammer" }
|
|
7
7
|
}
|
package/bin/spor.js
CHANGED
|
@@ -106,6 +106,38 @@ function cmdInit(cfg) {
|
|
|
106
106
|
return 0;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Detect the dead-mute condition for `spor status` (issue-spor-local-mode-queue-
|
|
110
|
+
// mute-noop): the local graph carries a `queue_mute` on at least one person node,
|
|
111
|
+
// but this box's git identity binds to NO matching person node, or to one that
|
|
112
|
+
// holds no mute — so the mutes silently do nothing for this viewer. Returns a
|
|
113
|
+
// one-line note, or null when there's nothing to warn about (no mutes anywhere,
|
|
114
|
+
// or the viewer's own mute IS active). Fail-open: any load / git failure returns
|
|
115
|
+
// null (status must never crash). The graph dir is the same nodesDir cmdStatus
|
|
116
|
+
// already resolved; the git identity is read from the dir that holds the nodes,
|
|
117
|
+
// matching lib/queue.js's gitFront/viewerFor wiring.
|
|
118
|
+
function localMuteNoOp(nodesDir) {
|
|
119
|
+
try {
|
|
120
|
+
if (!fs.existsSync(nodesDir)) return null;
|
|
121
|
+
const graphLib = require(path.join(ROOT, "lib", "graph.js"));
|
|
122
|
+
const queueLib = require(path.join(ROOT, "lib", "queue.js"));
|
|
123
|
+
const g = graphLib.loadGraph(nodesDir);
|
|
124
|
+
// Any person node carrying a non-empty queue_mute register?
|
|
125
|
+
const muters = Object.values(g.nodes).filter(
|
|
126
|
+
(n) => n.type === "person" && Array.isArray(n.queue_mute) && n.queue_mute.length);
|
|
127
|
+
if (!muters.length) return null; // no mutes set anywhere — nothing to warn about
|
|
128
|
+
const email = queueLib.gitIdentityEmail(path.dirname(nodesDir));
|
|
129
|
+
const viewer = queueLib.viewerFor(g, email);
|
|
130
|
+
// The viewer resolves to a person who actually carries a mute -> mutes are
|
|
131
|
+
// live for this box; no note. (Even an all-expired register counts as wired —
|
|
132
|
+
// the rot is the validator's/kernel's concern, not a binding failure.)
|
|
133
|
+
if (viewer && Array.isArray(viewer.queue_mute) && viewer.queue_mute.length) return null;
|
|
134
|
+
const who = email || "unset";
|
|
135
|
+
return `queue_mute is set on a person node but your git identity (${who}) resolves to ${viewer ? "a person node without a queue_mute" : "no matching person node"} — mutes are inactive`;
|
|
136
|
+
} catch {
|
|
137
|
+
return null; // fail-open: never break status on a graph/git error
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
109
141
|
async function cmdStatus(cfg) {
|
|
110
142
|
const mode = cfg.mode();
|
|
111
143
|
const home = cfg.graphHome();
|
|
@@ -146,6 +178,15 @@ async function cmdStatus(cfg) {
|
|
|
146
178
|
out(` remote). Pick one surface: set SPOR_SERVER/SPOR_TOKEN to go fully remote,`);
|
|
147
179
|
out(` or disable the claude.ai Spor connector to stay fully local.`);
|
|
148
180
|
}
|
|
181
|
+
// Dead-mute observability (issue-spor-local-mode-queue-mute-noop). Per-viewer
|
|
182
|
+
// queue_mute is wired locally now (lib/queue.js viewerFor binds the git
|
|
183
|
+
// identity to its person node), but it is still a no-op when the graph carries
|
|
184
|
+
// a queue_mute somewhere yet THIS box's git identity resolves to no matching
|
|
185
|
+
// person node (or a person node that holds no mute) — exactly the silent half
|
|
186
|
+
// of the issue. Surface it so the condition is observable instead of mystifying.
|
|
187
|
+
// Best-effort + fail-open: any load/git error skips the note (never crashes status).
|
|
188
|
+
const muteNote = localMuteNoOp(nodesDir);
|
|
189
|
+
if (muteNote) out(`note: ${muteNote}`);
|
|
149
190
|
}
|
|
150
191
|
// The Node prerequisite (issue-spor-onboarding-no-node-silent-fail-open).
|
|
151
192
|
// Always surfaced so a box where the hooks silently no-op has a greppable
|
|
@@ -192,9 +233,18 @@ async function cmdWhoami(cfg) {
|
|
|
192
233
|
}
|
|
193
234
|
|
|
194
235
|
async function cmdNext(cfg, args) {
|
|
236
|
+
// Default queue scope (task-spor-queue-default-project-config): a
|
|
237
|
+
// `queue.project` cascade key pins the default --project in BOTH modes, fixing
|
|
238
|
+
// the asymmetry where remote defaulted to the cwd slug and local to global. An
|
|
239
|
+
// explicit --project always wins; `pinned` only fills the gap when no flag was
|
|
240
|
+
// given. Unset => byte-identical to before (remote keeps the cwd default, local
|
|
241
|
+
// keeps the global default — no safeSlug() injected locally).
|
|
242
|
+
const pi = args.indexOf("--project");
|
|
243
|
+
const explicit = pi >= 0 && args[pi + 1] ? args[pi + 1] : null;
|
|
244
|
+
const pinned = cfg.get("queue.project", null);
|
|
245
|
+
|
|
195
246
|
if (cfg.mode() === "remote") {
|
|
196
|
-
const
|
|
197
|
-
const slug = pi >= 0 && args[pi + 1] ? args[pi + 1] : safeSlug();
|
|
247
|
+
const slug = explicit ?? pinned ?? safeSlug();
|
|
198
248
|
const r = await remote.get(cfg, `/v1/queue?project=${encodeURIComponent(slug)}&limit=10`, { timeoutMs: 6000 });
|
|
199
249
|
if (r.transport) {
|
|
200
250
|
err(`offline — could not reach server (${r.error})`);
|
|
@@ -204,6 +254,16 @@ async function cmdNext(cfg, args) {
|
|
|
204
254
|
err(`queue error ${r.status}`);
|
|
205
255
|
return 1;
|
|
206
256
|
}
|
|
257
|
+
// Best-effort zero-match note (issue-spor-next-project-token-not-roundtrippable):
|
|
258
|
+
// unknown-token detection is authoritative only locally (where we hold the
|
|
259
|
+
// graph); remotely we can only observe an empty result for a SCOPED read and
|
|
260
|
+
// softly say so on stderr. The cwd-default firehose (no explicit/pinned scope)
|
|
261
|
+
// is deliberately not flagged — an empty default queue is normal, not a typo.
|
|
262
|
+
const scoped = explicit ?? pinned;
|
|
263
|
+
const count = (r.json && (r.json.count ?? (Array.isArray(r.json.items) ? r.json.items.length : null)));
|
|
264
|
+
if (scoped && count === 0) {
|
|
265
|
+
err(`project '${scoped}' returned an empty queue — check the slug / grouping id (the server scoped to it and found nothing)`);
|
|
266
|
+
}
|
|
207
267
|
if (args.includes("--json")) {
|
|
208
268
|
out(JSON.stringify(r.json));
|
|
209
269
|
return 0;
|
|
@@ -211,7 +271,12 @@ async function cmdNext(cfg, args) {
|
|
|
211
271
|
renderQueue(r.json);
|
|
212
272
|
return 0;
|
|
213
273
|
}
|
|
214
|
-
|
|
274
|
+
// local: byte-identical passthrough. When no --project was given but a default
|
|
275
|
+
// is pinned, inject it so the local read inherits the same default scope as
|
|
276
|
+
// remote; otherwise pass args untouched (preserving the local->global default —
|
|
277
|
+
// we never inject safeSlug() locally).
|
|
278
|
+
const localArgs = !explicit && pinned ? [...args, "--project", pinned] : args;
|
|
279
|
+
return passthrough("queue.js", localArgs);
|
|
215
280
|
}
|
|
216
281
|
|
|
217
282
|
function renderQueue(q) {
|
|
@@ -475,6 +540,21 @@ function cmdValidate(cfg, args) {
|
|
|
475
540
|
return passthrough("validate.js", args);
|
|
476
541
|
}
|
|
477
542
|
|
|
543
|
+
// query enumerates a LOCAL graph (lib/query.js) — the structured node/edge
|
|
544
|
+
// list that `get`/`next`/`compile --query` are not (task-spor-local-graph-query-
|
|
545
|
+
// verb). It is the local-mode primitive under remote mode's saved render_lens
|
|
546
|
+
// views, so like validate it is local-only: in remote mode there is no local
|
|
547
|
+
// loadGraph, so fail fast naming that unless --nodes points at a local checkout.
|
|
548
|
+
function cmdQuery(cfg, args) {
|
|
549
|
+
if (cfg.mode() === "remote" && !namesLocalGraph(args)) {
|
|
550
|
+
err("query enumerates a LOCAL graph; in remote mode the server holds the graph,");
|
|
551
|
+
err(" so use a saved view instead (spor lens). Point --nodes at a local checkout");
|
|
552
|
+
err(" to query it, or unset SPOR_SERVER to query the local graph home.");
|
|
553
|
+
return 1;
|
|
554
|
+
}
|
|
555
|
+
return passthrough("query.js", args);
|
|
556
|
+
}
|
|
557
|
+
|
|
478
558
|
// --- spor add / capture -------------------------------------------------
|
|
479
559
|
// Local: write a well-formed node so a user never has to learn the frontmatter
|
|
480
560
|
// (issue-cc-local-mode-capture-queue-surfacing-gap). Remote: POST /v1/capture,
|
|
@@ -1926,9 +2006,9 @@ const COMMANDS = {
|
|
|
1926
2006
|
next: {
|
|
1927
2007
|
group: "Graph", parse: "raw", args: "[--project S]", aliases: ["queue"],
|
|
1928
2008
|
summary: "the decision queue (local: lib/queue; remote: /v1/queue)",
|
|
1929
|
-
help: "Show the ranked decision queue. Remote mode reads /v1/queue; local mode is a\nbyte-identical passthrough to lib/queue.js, so it also accepts that script's\nflags (--days, --no-front, --limit, --name-only, --nodes).",
|
|
2009
|
+
help: "Show the ranked decision queue. Remote mode reads /v1/queue; local mode is a\nbyte-identical passthrough to lib/queue.js, so it also accepts that script's\nflags (--days, --no-front, --limit, --name-only, --nodes).\n\n--project accepts a repo slug (-> its home-project grouping union), a repo-<slug>\nnode id (-> that single repo), or a grouping id (-> the grouping union); an\nunknown token warns and yields an empty queue. Pin a default scope for both modes\nwith the queue.project config key (SPOR_QUEUE_PROJECT or .spor.json\n{\"queue\":{\"project\":\"...\"}}); an explicit --project still wins.",
|
|
1930
2010
|
options: {
|
|
1931
|
-
project: { type: "string", value: "S", desc: "scope to a project slug (default: inferred)" },
|
|
2011
|
+
project: { type: "string", value: "S", desc: "scope to a project slug (default: queue.project config, else inferred)" },
|
|
1932
2012
|
json: { type: "boolean", desc: "machine-readable JSON output" },
|
|
1933
2013
|
},
|
|
1934
2014
|
examples: ["spor next", "spor next --project spor --json"],
|
|
@@ -1955,6 +2035,39 @@ const COMMANDS = {
|
|
|
1955
2035
|
examples: ["spor lens", "spor lens lens-roadmap", "spor lens lens-roadmap --project spor"],
|
|
1956
2036
|
run: (cfg, args) => cmdLens(cfg, args),
|
|
1957
2037
|
},
|
|
2038
|
+
query: {
|
|
2039
|
+
group: "Graph", parse: "raw", args: "[--type T] [--where k=v] [--edges]",
|
|
2040
|
+
summary: "filterable node/edge enumeration (local)",
|
|
2041
|
+
help:
|
|
2042
|
+
"Deterministic, filterable enumeration over the local graph — the structured\n" +
|
|
2043
|
+
"list that `get` (one node), `next` (the ranked queue) and `compile --query`\n" +
|
|
2044
|
+
"(semantic search) are not. Pure, no LLM. Local-only — it reads the local nodes\n" +
|
|
2045
|
+
"dir; in remote mode use the server's saved `render_lens` views instead (point\n" +
|
|
2046
|
+
"--nodes at a local checkout to query one under a server).\n" +
|
|
2047
|
+
"\n" +
|
|
2048
|
+
"Node selection (AND across distinct flags):\n" +
|
|
2049
|
+
" --type <T> nodes of that type: (repeatable -> OR within type)\n" +
|
|
2050
|
+
" --where key=val match a frontmatter field (repeatable -> AND); a list\n" +
|
|
2051
|
+
" field (e.g. tags) matches on membership\n" +
|
|
2052
|
+
" --id-prefix <p> ids starting with <p>\n" +
|
|
2053
|
+
"\n" +
|
|
2054
|
+
"Edge emission (switches output from nodes to {from,type,to} edges; the node\n" +
|
|
2055
|
+
"predicates above then restrict each emitted edge's SOURCE):\n" +
|
|
2056
|
+
" --edges emit edges instead of nodes\n" +
|
|
2057
|
+
" --edge-type <T> filter edges by type\n" +
|
|
2058
|
+
" --from <id> out-edges whose source is <id>\n" +
|
|
2059
|
+
" --to <id> in-edges whose target is <id>\n" +
|
|
2060
|
+
"\n" +
|
|
2061
|
+
"Projection: default table; --ids (one id per line), --summary (id + summary),\n" +
|
|
2062
|
+
"--full (raw node block), --json (machine output). --nodes <dir> overrides the\n" +
|
|
2063
|
+
"graph dir.",
|
|
2064
|
+
examples: [
|
|
2065
|
+
"spor query --type repo --ids",
|
|
2066
|
+
"spor query --where status=open --type task --json",
|
|
2067
|
+
"spor query --edges --edge-type grouped-under --to proj-rdi",
|
|
2068
|
+
],
|
|
2069
|
+
run: (cfg, args) => cmdQuery(cfg, args),
|
|
2070
|
+
},
|
|
1958
2071
|
|
|
1959
2072
|
// --- Repo scoping ---
|
|
1960
2073
|
disable: {
|
package/lib/config.js
CHANGED
|
@@ -85,6 +85,7 @@ const ENV_MAP = [
|
|
|
85
85
|
["INFER_THRESHOLD", "inferCommits.threshold"],
|
|
86
86
|
["QUEUE_FRONT", "queue.front.enabled"], // SPOR_QUEUE_FRONT=0 disables local git-derived front
|
|
87
87
|
["QUEUE_FRONT_DAYS", "queue.front.days"], // rolling front window (days)
|
|
88
|
+
["QUEUE_PROJECT", "queue.project"], // default --project scope for `spor next` (both modes); explicit --project wins
|
|
88
89
|
];
|
|
89
90
|
|
|
90
91
|
function isPlainObject(v) {
|
package/lib/graph.js
CHANGED
|
@@ -93,6 +93,9 @@ module.exports = {
|
|
|
93
93
|
// shared read scope resolver (dec-spor-queue-slug-resolves-to-grouping):
|
|
94
94
|
// bare slug -> home grouping union; repo node id -> single repo
|
|
95
95
|
scopeFor: kernel.scopeFor,
|
|
96
|
+
// zero-match --project detection (issue-spor-next-project-token-not-roundtrippable):
|
|
97
|
+
// is a project token a known repo/grouping/alias, or scopeFor's fall-to-self?
|
|
98
|
+
projectKnown: kernel.projectKnown,
|
|
96
99
|
// project end-of-life (issue-cc-project-lifecycle-queue-pollution)
|
|
97
100
|
isArchivedProject: kernel.isArchivedProject,
|
|
98
101
|
// registry (QUEUE.md §2): seed pack + graph-resident schema nodes
|
package/lib/kernel/graph.js
CHANGED
|
@@ -284,6 +284,25 @@ function scopeFor(graph, param) {
|
|
|
284
284
|
return grouping ? gr[grouping] : new Set([repoId]);
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
// Does a `--project` token actually name something in this graph
|
|
288
|
+
// (issue-spor-next-project-token-not-roundtrippable)? A token is KNOWN when it
|
|
289
|
+
// is an existing node id (a `type: repo` or `type: project` grouping node — what
|
|
290
|
+
// scopeFor pins or unions), a repo slug in the alias map, or a member/own key of
|
|
291
|
+
// some grouping. An UNKNOWN token is exactly scopeFor's fall-back-to-itself case
|
|
292
|
+
// (`new Set([param])` against a non-existent repo id) — it yields count:0 with
|
|
293
|
+
// no warning. This reuses the existing resolvers (projectAliases / groupingOf)
|
|
294
|
+
// rather than re-deriving the registry, so a bare slug that legitimately
|
|
295
|
+
// up-resolves to its grouping (dec-spor-queue-slug-resolves-to-grouping) is
|
|
296
|
+
// reported KNOWN — the deliberate semantics are untouched. Empty/falsy token =>
|
|
297
|
+
// "known" (the global, unscoped read), so a caller that never passed --project
|
|
298
|
+
// never warns.
|
|
299
|
+
function projectKnown(graph, param) {
|
|
300
|
+
if (!param) return true;
|
|
301
|
+
if (graph.nodes?.[param]) return true;
|
|
302
|
+
if (graph.projectAliases?.[param]) return true;
|
|
303
|
+
return groupingOf(graph, param) != null;
|
|
304
|
+
}
|
|
305
|
+
|
|
287
306
|
// The grouping id a project-scope KEY belongs to, or null when it is not part of
|
|
288
307
|
// any grouping. A grouping's own id maps to itself; a member repo maps to its
|
|
289
308
|
// grouping. Used by the brief surface — a repo's product brief is
|
|
@@ -1024,6 +1043,7 @@ module.exports = {
|
|
|
1024
1043
|
resolveProject,
|
|
1025
1044
|
groupingOf,
|
|
1026
1045
|
scopeFor,
|
|
1046
|
+
projectKnown,
|
|
1027
1047
|
isArchivedProject,
|
|
1028
1048
|
compile,
|
|
1029
1049
|
renderSkeleton,
|
package/lib/query.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// query.js — a pure, deterministic, filterable enumeration over a loaded
|
|
2
|
+
// graph's nodes AND edges (task-spor-local-graph-query-verb). The local-mode
|
|
3
|
+
// primitive under what remote mode offers as saved `render_lens` views: `get`
|
|
4
|
+
// is one node, `next` is the ranked queue, `compile --query` is semantic
|
|
5
|
+
// search — `query` is the structured, predicate-filtered list. No LLM, no
|
|
6
|
+
// ranking, no graph walk: it reads `graph.nodes` (and each node's `edges`)
|
|
7
|
+
// directly and returns plain data. Zero deps.
|
|
8
|
+
//
|
|
9
|
+
// Also a CLI (local mode / debugging):
|
|
10
|
+
// node lib/query.js [--nodes <dir>]
|
|
11
|
+
// # node selection (AND across distinct flags):
|
|
12
|
+
// [--type <T> ...] nodes of that type: (repeatable -> OR within type)
|
|
13
|
+
// [--where key=value ...] match a frontmatter field (repeatable -> AND);
|
|
14
|
+
// a list field matches on membership
|
|
15
|
+
// [--id-prefix <p>] ids starting with <p>
|
|
16
|
+
// # edge emission (switches output to edges):
|
|
17
|
+
// [--edges] emit {from,type,to} edges, not nodes
|
|
18
|
+
// [--edge-type <T>] filter edges by type
|
|
19
|
+
// [--from <id>] out-edges whose SOURCE is <id>
|
|
20
|
+
// [--to <id>] in-edges whose TARGET is <id>
|
|
21
|
+
// # projection:
|
|
22
|
+
// [--ids | --summary | --full | --json]
|
|
23
|
+
|
|
24
|
+
// Resolve a frontmatter field as a comparable. Lists (edges/pin/exclude and the
|
|
25
|
+
// parser's inline-list registers) match on membership; everything else compares
|
|
26
|
+
// as a string. Absent -> undefined (never matches).
|
|
27
|
+
function fieldMatches(node, key, value) {
|
|
28
|
+
const v = node[key];
|
|
29
|
+
if (v === undefined || v === null) return false;
|
|
30
|
+
if (Array.isArray(v)) return v.map(String).includes(value);
|
|
31
|
+
return String(v) === value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// queryGraph(graph, opts) -> { nodes } | { edges }
|
|
35
|
+
// opts.types : string[] — OR set over node `type:` (empty = any)
|
|
36
|
+
// opts.where : [key, value][] — all must match (AND); list = membership
|
|
37
|
+
// opts.idPrefix : string — id startsWith
|
|
38
|
+
// opts.edges : boolean — emit edges instead of nodes
|
|
39
|
+
// opts.edgeType : string — filter emitted edges by type
|
|
40
|
+
// opts.from : string — out-edges from this source id
|
|
41
|
+
// opts.to : string — in-edges to this target id
|
|
42
|
+
// Node predicates (types/where/idPrefix) restrict the candidate NODE set; in
|
|
43
|
+
// edge mode they restrict the SOURCE node of each emitted edge.
|
|
44
|
+
function queryGraph(graph, opts = {}) {
|
|
45
|
+
const types = opts.types && opts.types.length ? opts.types : null;
|
|
46
|
+
const where = opts.where || [];
|
|
47
|
+
const idPrefix = opts.idPrefix || null;
|
|
48
|
+
|
|
49
|
+
const nodeMatches = (n) => {
|
|
50
|
+
if (types && !types.includes(n.type)) return false;
|
|
51
|
+
if (idPrefix && !String(n.id).startsWith(idPrefix)) return false;
|
|
52
|
+
for (const [k, val] of where) if (!fieldMatches(n, k, val)) return false;
|
|
53
|
+
return true;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Deterministic order: sort node ids once, reuse for nodes and edge sources.
|
|
57
|
+
const ids = Object.keys(graph.nodes).sort();
|
|
58
|
+
|
|
59
|
+
if (opts.edges) {
|
|
60
|
+
const edges = [];
|
|
61
|
+
for (const id of ids) {
|
|
62
|
+
const n = graph.nodes[id];
|
|
63
|
+
if (!nodeMatches(n)) continue; // node predicates restrict the edge SOURCE
|
|
64
|
+
if (opts.to != null) continue; // --to walks INTO a target below, not out
|
|
65
|
+
for (const e of n.edges || []) {
|
|
66
|
+
if (opts.edgeType && e.type !== opts.edgeType) continue;
|
|
67
|
+
if (opts.from != null && id !== opts.from) continue;
|
|
68
|
+
edges.push({ from: id, type: e.type, to: e.to });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// --to: in-edges whose target is <id>. Walk every source's out-edges (a
|
|
72
|
+
// node only stores its own out-edges) and keep those pointing at --to.
|
|
73
|
+
if (opts.to != null) {
|
|
74
|
+
for (const id of ids) {
|
|
75
|
+
const n = graph.nodes[id];
|
|
76
|
+
if (!nodeMatches(n)) continue; // predicates still scope the SOURCE
|
|
77
|
+
for (const e of n.edges || []) {
|
|
78
|
+
if (e.to !== opts.to) continue;
|
|
79
|
+
if (opts.edgeType && e.type !== opts.edgeType) continue;
|
|
80
|
+
if (opts.from != null && id !== opts.from) continue;
|
|
81
|
+
edges.push({ from: id, type: e.type, to: e.to });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { edges };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const nodes = [];
|
|
89
|
+
for (const id of ids) {
|
|
90
|
+
const n = graph.nodes[id];
|
|
91
|
+
if (nodeMatches(n)) nodes.push(n);
|
|
92
|
+
}
|
|
93
|
+
return { nodes };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { queryGraph, fieldMatches };
|
|
97
|
+
|
|
98
|
+
// ---------- CLI (local mode / debugging) ----------
|
|
99
|
+
|
|
100
|
+
if (require.main === module) {
|
|
101
|
+
const fs = require("fs");
|
|
102
|
+
const path = require("path");
|
|
103
|
+
const graphLib = require(path.join(__dirname, "graph.js"));
|
|
104
|
+
|
|
105
|
+
const argv = process.argv.slice(2);
|
|
106
|
+
// single-value flag reader (last wins), mirroring lib/queue.js's opt().
|
|
107
|
+
const opt = (n, d) => {
|
|
108
|
+
let v = d;
|
|
109
|
+
for (let i = 0; i < argv.length - 1; i++) if (argv[i] === `--${n}`) v = argv[i + 1];
|
|
110
|
+
return v;
|
|
111
|
+
};
|
|
112
|
+
// repeatable-value flag reader (collect every --flag VALUE pair).
|
|
113
|
+
const optAll = (n) => {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (let i = 0; i < argv.length - 1; i++) if (argv[i] === `--${n}`) out.push(argv[i + 1]);
|
|
116
|
+
return out;
|
|
117
|
+
};
|
|
118
|
+
const has = (n) => argv.includes(`--${n}`);
|
|
119
|
+
|
|
120
|
+
// Client config cascade (dec-spor-client-config-cascade); nodesDir() honors
|
|
121
|
+
// config.nodes / SPOR_NODES then the graph-home default — byte-identical when
|
|
122
|
+
// no config is set. --nodes is the highest-precedence override (like queue.js).
|
|
123
|
+
const cfg = require(path.join(__dirname, "config.js")).loadConfig({ cwd: process.cwd() });
|
|
124
|
+
const NODES_DIR = path.resolve(opt("nodes", cfg.nodesDir()));
|
|
125
|
+
if (!fs.existsSync(NODES_DIR)) {
|
|
126
|
+
console.error(`no Spor graph at ${NODES_DIR}`);
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
129
|
+
const g = graphLib.loadGraph(NODES_DIR);
|
|
130
|
+
|
|
131
|
+
// --where key=value (repeatable). Split on the FIRST '=' so a value may
|
|
132
|
+
// itself contain '='.
|
|
133
|
+
const where = optAll("where").map((s) => {
|
|
134
|
+
const i = s.indexOf("=");
|
|
135
|
+
return i < 0 ? [s, ""] : [s.slice(0, i), s.slice(i + 1)];
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const r = queryGraph(g, {
|
|
139
|
+
types: optAll("type"),
|
|
140
|
+
where,
|
|
141
|
+
idPrefix: opt("id-prefix", null),
|
|
142
|
+
edges: has("edges"),
|
|
143
|
+
edgeType: opt("edge-type", null),
|
|
144
|
+
from: has("from") ? opt("from", null) : null,
|
|
145
|
+
to: has("to") ? opt("to", null) : null,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (has("json")) {
|
|
149
|
+
if (r.edges) process.stdout.write(JSON.stringify(r.edges, null, 2) + "\n");
|
|
150
|
+
else {
|
|
151
|
+
// Strip the parser's internal `file` field from JSON node output (it's a
|
|
152
|
+
// load-time artifact, not graph data).
|
|
153
|
+
const clean = r.nodes.map(({ file, ...rest }) => rest);
|
|
154
|
+
process.stdout.write(JSON.stringify(clean, null, 2) + "\n");
|
|
155
|
+
}
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (r.edges) {
|
|
160
|
+
if (!r.edges.length) console.log("no matching edges");
|
|
161
|
+
for (const e of r.edges) console.log(`${e.from} --${e.type}--> ${e.to}`);
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const nodes = r.nodes;
|
|
166
|
+
if (has("ids")) {
|
|
167
|
+
for (const n of nodes) console.log(n.id);
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
if (has("summary")) {
|
|
171
|
+
if (!nodes.length) console.log("no matching nodes");
|
|
172
|
+
for (const n of nodes) console.log(`${n.id} ${n.summary || n.title || ""}`.trimEnd());
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
if (has("full")) {
|
|
176
|
+
if (!nodes.length) console.log("no matching nodes");
|
|
177
|
+
// Compact frontmatter dump: the node's own raw file if readable, else a
|
|
178
|
+
// reconstructed key/edge view. Mirrors the spirit of `spor get`.
|
|
179
|
+
for (const n of nodes) {
|
|
180
|
+
const file = n.file && path.join(NODES_DIR, n.file);
|
|
181
|
+
let raw = null;
|
|
182
|
+
try { if (file) raw = fs.readFileSync(file, "utf8"); } catch { /* fall through */ }
|
|
183
|
+
if (raw != null) {
|
|
184
|
+
process.stdout.write(raw.endsWith("\n") ? raw : raw + "\n");
|
|
185
|
+
} else {
|
|
186
|
+
const { file: _f, ...rest } = n; // drop the load-time artifact
|
|
187
|
+
process.stdout.write(JSON.stringify(rest, null, 2) + "\n");
|
|
188
|
+
}
|
|
189
|
+
console.log("---");
|
|
190
|
+
}
|
|
191
|
+
process.exit(0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// default human table.
|
|
195
|
+
if (!nodes.length) {
|
|
196
|
+
console.log("no matching nodes");
|
|
197
|
+
process.exit(0);
|
|
198
|
+
}
|
|
199
|
+
const idW = Math.max(...nodes.map((n) => n.id.length), 2);
|
|
200
|
+
const tyW = Math.max(...nodes.map((n) => (n.type || "").length), 4);
|
|
201
|
+
for (const n of nodes) {
|
|
202
|
+
const title = n.title || n.summary || "";
|
|
203
|
+
console.log(`${n.id.padEnd(idW)} ${(n.type || "").padEnd(tyW)} ${title}`.trimEnd());
|
|
204
|
+
}
|
|
205
|
+
console.log(`(${nodes.length} node${nodes.length === 1 ? "" : "s"})`);
|
|
206
|
+
}
|
package/lib/queue.js
CHANGED
|
@@ -34,6 +34,40 @@ function rankQueue(graph, opts = {}) {
|
|
|
34
34
|
// two nearly coincide, and front is the honest viewer-scoped signal. The window
|
|
35
35
|
// and the on/off toggle live in the config cascade (queue.front.days /
|
|
36
36
|
// queue.front.enabled); the CLI flags --days/--no-front map onto them.
|
|
37
|
+
// The local git identity (`git config user.email`) of a repo, trimmed, or "" on
|
|
38
|
+
// any failure (not a git repo, no identity, missing binary). This IS the local
|
|
39
|
+
// $viewer comparand (dec-viewer-token-binding's local analogue: remote derives
|
|
40
|
+
// $viewer from the authenticated token, local derives it from git config) — the
|
|
41
|
+
// same read gitFront already did for front attribution, factored out so the
|
|
42
|
+
// viewer wiring and the front map share one source of truth. Best-effort +
|
|
43
|
+
// fail-open like every other local-mode path.
|
|
44
|
+
function gitIdentityEmail(repoDir) {
|
|
45
|
+
const { spawnSync } = require("child_process");
|
|
46
|
+
const r = spawnSync("git", ["-C", repoDir, "config", "user.email"], {
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
49
|
+
});
|
|
50
|
+
return r.status === 0 ? (r.stdout || "").trim() : "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Resolve a git email to the graph's `type: person` node by matching its
|
|
54
|
+
// `email:` field (trim + case-insensitive). The LOCAL analogue of
|
|
55
|
+
// dec-viewer-token-binding: remote binds $viewer from the authenticated token's
|
|
56
|
+
// email, local binds it from `git config user.email` — identity derives ONLY
|
|
57
|
+
// from git config, never from a caller-supplied parameter. Returns the person
|
|
58
|
+
// node, or null when there is no email / no match — so an unbound identity
|
|
59
|
+
// simply mutes nothing (activeMutes(null) is the empty set, byte-identical to a
|
|
60
|
+
// queue read with no viewer).
|
|
61
|
+
function viewerFor(graph, email) {
|
|
62
|
+
const want = String(email || "").trim().toLowerCase();
|
|
63
|
+
if (!want) return null;
|
|
64
|
+
for (const n of Object.values(graph.nodes)) {
|
|
65
|
+
if (n.type !== "person") continue;
|
|
66
|
+
if (typeof n.email === "string" && n.email.trim().toLowerCase() === want) return n;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
function gitFront(repoDir, nodesName, days) {
|
|
38
72
|
const { spawnSync } = require("child_process");
|
|
39
73
|
const out = {};
|
|
@@ -44,7 +78,7 @@ function gitFront(repoDir, nodesName, days) {
|
|
|
44
78
|
});
|
|
45
79
|
return r.status === 0 ? r.stdout : null;
|
|
46
80
|
};
|
|
47
|
-
const email = (
|
|
81
|
+
const email = gitIdentityEmail(repoDir);
|
|
48
82
|
// --author is a regex matched against name OR email; anchor the email so a
|
|
49
83
|
// substring of another author's line can't false-match. Unset identity ->
|
|
50
84
|
// unfiltered (still useful on a graph committed under mixed identities,
|
|
@@ -64,7 +98,7 @@ function gitFront(repoDir, nodesName, days) {
|
|
|
64
98
|
return out;
|
|
65
99
|
}
|
|
66
100
|
|
|
67
|
-
module.exports = { rankQueue, gitFront, PRIORITY_BUMP: kernel.PRIORITY_BUMP, DEFAULT_LIMIT: kernel.DEFAULT_LIMIT };
|
|
101
|
+
module.exports = { rankQueue, gitFront, gitIdentityEmail, viewerFor, PRIORITY_BUMP: kernel.PRIORITY_BUMP, DEFAULT_LIMIT: kernel.DEFAULT_LIMIT };
|
|
68
102
|
|
|
69
103
|
// ---------- CLI (local mode / debugging; new entry point, existing CLIs untouched) ----------
|
|
70
104
|
|
|
@@ -101,13 +135,32 @@ if (require.main === module) {
|
|
|
101
135
|
: cfg.getNum("queue.front.days", 7);
|
|
102
136
|
const frontOn = !argv.includes("--no-front") && cfg.getBool("queue.front.enabled", true);
|
|
103
137
|
const front = frontOn ? gitFront(path.dirname(NODES_DIR), path.basename(NODES_DIR), days) : null;
|
|
138
|
+
// Local $viewer binding (issue-spor-local-mode-queue-mute-noop): per-viewer
|
|
139
|
+
// queue_mute was dead locally because no viewer was ever constructed, so
|
|
140
|
+
// activeMutes(undefined) was always empty. Resolve the git identity to its
|
|
141
|
+
// person node (viewerFor) and pass it through — the kernel already honors
|
|
142
|
+
// viewer.queue_mute. null when the identity matches no person node, so a graph
|
|
143
|
+
// with no person nodes (or an unmatched identity) is byte-identical to the
|
|
144
|
+
// pre-viewer read (no mutes, no muted count).
|
|
145
|
+
const viewer = viewerFor(g, gitIdentityEmail(path.dirname(NODES_DIR)));
|
|
146
|
+
const project = opt("project", null);
|
|
104
147
|
const r = rankQueue(g, {
|
|
105
|
-
project
|
|
148
|
+
project,
|
|
106
149
|
assignee: opt("assignee", null),
|
|
107
150
|
limit: parseInt(opt("limit", String(kernel.DEFAULT_LIMIT)), 10),
|
|
108
151
|
front,
|
|
109
152
|
frontDays: days,
|
|
153
|
+
viewer,
|
|
110
154
|
});
|
|
155
|
+
// Fail loud on a zero-match --project (issue-spor-next-project-token-not-
|
|
156
|
+
// roundtrippable): a token that resolves to none of the graph's repos /
|
|
157
|
+
// groupings / aliases silently yielded count:0 (scopeFor falls back to the
|
|
158
|
+
// token itself). Detect that unknown case (NOT the deliberate bare-slug ->
|
|
159
|
+
// grouping up-resolution, which is a known token) and say so on stderr; still
|
|
160
|
+
// exit 0 (fail-open). A known token behaves exactly as in 0.4.x.
|
|
161
|
+
if (project && !graphLib.projectKnown(g, project)) {
|
|
162
|
+
console.error(`project '${project}' matched no repo or grouping — queue is empty (try a repo slug, a repo-<slug> node id, or a grouping id)`);
|
|
163
|
+
}
|
|
111
164
|
if (argv.includes("--json")) {
|
|
112
165
|
process.stdout.write(JSON.stringify(r, null, 2) + "\n");
|
|
113
166
|
} else {
|
|
@@ -117,5 +170,9 @@ if (require.main === module) {
|
|
|
117
170
|
console.log(` ${it.why}`);
|
|
118
171
|
}
|
|
119
172
|
if (r.count > r.items.length) console.log(`(${r.count - r.items.length} more — raise --limit)`);
|
|
173
|
+
// The kernel already surfaces the muted count in r.muted (and --json carries
|
|
174
|
+
// it); mirror it on the human path so the per-viewer hiding is never silent,
|
|
175
|
+
// the same way the "more — raise --limit" overflow is reported.
|
|
176
|
+
if (r.muted > 0) console.log(`(${r.muted} muted — your queue_mute)`);
|
|
120
177
|
}
|
|
121
178
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sporhq/spor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Spor — a shared memory substrate for teams and agents. Decisions, their reasons, and the traces they leave. Knowledge-graph context compiler: session-start briefings, per-prompt digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Anthony Allen",
|
package/skills/brief/SKILL.md
CHANGED
|
@@ -37,14 +37,26 @@ Steps:
|
|
|
37
37
|
**Local mode (personal graph) — `SPOR_SERVER` unset:** first resolve the
|
|
38
38
|
plugin root — `${CLAUDE_PLUGIN_ROOT}` is empty in the Bash tool, so read
|
|
39
39
|
the path the session-start hook cached
|
|
40
|
-
(issue-cc-skill-plugin-root-unsubstituted)
|
|
40
|
+
(issue-cc-skill-plugin-root-unsubstituted) — then resolve THIS repo's
|
|
41
|
+
project slug the same way a session does, so the compile is scoped to the
|
|
42
|
+
repo you're in instead of project-blind:
|
|
41
43
|
```bash
|
|
42
44
|
SPOR_ROOT="$(cat "${SPOR_HOME:-$HOME/.spor}/cache/plugin-root" 2>/dev/null \
|
|
43
45
|
|| cat "$HOME/.substrate/cache/plugin-root" 2>/dev/null)"
|
|
44
46
|
SPOR_ROOT="${SPOR_ROOT:-$CLAUDE_PLUGIN_ROOT}"
|
|
47
|
+
SLUG="$(node -e 'process.stdout.write(require("'"$SPOR_ROOT"'/scripts/engines/util.js").projectSlug(process.cwd()))' 2>/dev/null)"
|
|
45
48
|
```
|
|
46
|
-
- node id: `node "$SPOR_ROOT/lib/compile.js" --root <id> --skeleton`
|
|
47
|
-
- query: `node "$SPOR_ROOT/lib/compile.js" --query "<text>"`
|
|
49
|
+
- node id: `node "$SPOR_ROOT/lib/compile.js" --root <id> --skeleton ${SLUG:+--project "$SLUG"}`
|
|
50
|
+
- query: `node "$SPOR_ROOT/lib/compile.js" --query "<text>" ${SLUG:+--project "$SLUG"}`
|
|
51
|
+
|
|
52
|
+
**Always pass `--project "$SLUG"`.** Without it, `compile --root`/`--query`
|
|
53
|
+
run *project-blind* (`sessionProject == null`): the org-norm ride-along then
|
|
54
|
+
keeps every `always_on` norm and ignores `applies_to_repos`/`applies_to_tags`/
|
|
55
|
+
`applies_to_projects` scoping entirely — so an unscoped briefing shows norms a
|
|
56
|
+
real session in this repo would filter out (the `applies_to_*` selectors match
|
|
57
|
+
the SESSION repo, not the `--root` node's repo). `--project` makes the compile
|
|
58
|
+
match what session-start actually injects here.
|
|
59
|
+
|
|
48
60
|
(The compiler defaults to the global graph at `$SPOR_HOME/nodes`, falling
|
|
49
61
|
back to `~/.spor/nodes` — or a pre-existing `~/.substrate/nodes` when
|
|
50
62
|
`~/.spor` is absent.)
|
package/skills/spor/SKILL.md
CHANGED
|
@@ -93,13 +93,38 @@ spor brief <id> # a briefing for one node (compile --root <id>)
|
|
|
93
93
|
# local (personal graph) only — fail fast with a redirect in remote mode
|
|
94
94
|
spor validate # lint the local graph (server validates per-write remotely)
|
|
95
95
|
spor compile --root <id> --skeleton # writes a local briefing-node skeleton
|
|
96
|
+
spor query --type task --where status=open --ids # structured node/edge enumeration
|
|
96
97
|
```
|
|
97
98
|
|
|
99
|
+
`spor query` is the local structured enumeration — the deterministic,
|
|
100
|
+
predicate-filtered list that `get` (one node), `next` (the ranked queue) and
|
|
101
|
+
`compile --query` (semantic search) are not; it is the local-mode primitive
|
|
102
|
+
under what remote mode offers as saved `render_lens` views (use `spor lens`
|
|
103
|
+
there). It AND-combines node predicates — `--type <T>` (repeatable),
|
|
104
|
+
`--where key=value` (repeatable; a list field like `tags` matches on
|
|
105
|
+
membership), `--id-prefix <p>` — and with `--edges` emits `{from,type,to}`
|
|
106
|
+
edges instead, filterable by `--edge-type`, `--from <id>` (out-edges) and
|
|
107
|
+
`--to <id>` (in-edges), e.g. `spor query --edges --edge-type grouped-under
|
|
108
|
+
--to proj-rdi` answers "what is grouped under proj-rdi". Projections: default
|
|
109
|
+
table, `--ids`, `--summary`, `--full`, `--json`.
|
|
110
|
+
|
|
111
|
+
`spor next --project <token>` accepts three forms: a **repo slug** (resolves
|
|
112
|
+
*up* to its home-project grouping and unions the members — the intuitive token),
|
|
113
|
+
a **`repo-<slug>` node id** (pins that single repo — the escape hatch), or a
|
|
114
|
+
**grouping id `proj-<stem>`** (the grouping union). An unknown token warns on
|
|
115
|
+
stderr and yields an empty queue (it still exits 0). Pin a default scope for both
|
|
116
|
+
modes with the `queue.project` config key (`SPOR_QUEUE_PROJECT`, or `.spor.json`
|
|
117
|
+
`{"queue": {"project": "<token>"}}`); an explicit `--project` always wins.
|
|
118
|
+
|
|
98
119
|
`compile`/`brief` are mode-aware: local mode runs the in-repo compiler, remote
|
|
99
120
|
mode dispatches to the server (mirroring `/spor:brief`). Much of this is what
|
|
100
121
|
the session hooks already inject for you automatically; pulling one on demand
|
|
101
122
|
with `spor brief`, `spor compile`, or `/spor:brief` is the same briefing.
|
|
102
123
|
Passing `--nodes <dir>` always targets that local checkout, even under a server.
|
|
124
|
+
In local mode, add `--project <repo-slug>` to scope to a repo: without it
|
|
125
|
+
`compile --root`/`--query` run *project-blind* and the `always_on` norm
|
|
126
|
+
ride-along ignores `applies_to_*` scoping (every norm rides along) — pass it to
|
|
127
|
+
match what a real session in that repo sees. `/spor:brief` does this for you.
|
|
103
128
|
|
|
104
129
|
**In Cowork (Anthropic's chat workspace) and Claude Code with the connector**
|
|
105
130
|
there is no shell and no ambient injection — reach the graph through the
|