@sporhq/spor 0.4.2 → 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.
@@ -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.4.2",
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 pi = args.indexOf("--project");
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
- return passthrough("queue.js", args); // local: byte-identical
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
@@ -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 = (run(["config", "user.email"]) || "").trim();
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: opt("project", null),
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.4.2",
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",
@@ -93,8 +93,29 @@ 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