@sporhq/spor 0.16.0 → 0.17.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,7 +2,7 @@
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.16.0",
5
+ "version": "0.17.0",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
package/bin/spor.js CHANGED
@@ -107,7 +107,7 @@ function ensureInitialCommit(home) {
107
107
  if (git(home, ["rev-parse", "--verify", "-q", "HEAD"]).status === 0) return;
108
108
  git(home, ["add", "nodes"]); // nodes/ always exists (ensureGraphHome made it)
109
109
  if (fs.existsSync(path.join(home, ".gitignore"))) git(home, ["add", ".gitignore"]);
110
- git(home, ["commit", "-q", "--allow-empty", "-m", "spor: initialize graph"]);
110
+ git(home, [...u.NO_GPGSIGN, "commit", "-q", "--allow-empty", "-m", "spor: initialize graph"]);
111
111
  }
112
112
 
113
113
  // Idempotently create the local graph home (nodes/, git, .gitignore, a
@@ -630,10 +630,10 @@ async function fetchQueuePaged(cfg, baseQs, target) {
630
630
  return { ok: true, json: envelope };
631
631
  }
632
632
 
633
- async function cmdGet(cfg, { positionals }) {
633
+ async function cmdGet(cfg, { positionals, values }) {
634
634
  const id = positionals[0];
635
635
  if (!id) {
636
- err("usage: spor get <id>");
636
+ err("usage: spor get <id> [--json]");
637
637
  return 1;
638
638
  }
639
639
  if (cfg.mode() === "remote") {
@@ -650,18 +650,100 @@ async function cmdGet(cfg, { positionals }) {
650
650
  err(`error ${r.status}`);
651
651
  return 1;
652
652
  }
653
- out(r.json && r.json.raw ? r.json.raw : r.text);
653
+ if (!values.json) {
654
+ out(r.json && r.json.raw ? r.json.raw : r.text);
655
+ return 0;
656
+ }
657
+ // --json: parse the raw with the SAME lib parser as local (parity), take the
658
+ // server's git-blob-sha revision, and gather inbound edges from the team graph
659
+ // (the documented graph-wide sweep via GET /v1/export — there is no inbound
660
+ // endpoint, the same path `spor query --to` walks).
661
+ const graphLib = require(path.join(ROOT, "lib", "graph.js"));
662
+ const raw = r.json && r.json.raw;
663
+ if (typeof raw !== "string") {
664
+ err(`error: server returned no node body for ${id}`);
665
+ return 1;
666
+ }
667
+ const node = graphLib.parseFrontmatter(raw, `${id}.md`);
668
+ const fetched = await fetchRemoteExportNodes(cfg, "get");
669
+ if (fetched.error) return 1; // already reported
670
+ let inbound;
671
+ try {
672
+ inbound = inboundEdges(graphLib.loadGraph(fetched.nodesDir), node.id);
673
+ } finally {
674
+ fetched.cleanup();
675
+ }
676
+ out(JSON.stringify(getNodeJson(node, inbound, r.json.revision), null, 2));
654
677
  return 0;
655
678
  }
656
679
  // local: read the node file
657
- const f = path.join(cfg.nodesDir(), `${id}.md`);
680
+ const nodesDir = cfg.nodesDir();
681
+ const f = path.join(nodesDir, `${id}.md`);
682
+ if (!values.json) {
683
+ try {
684
+ out(fs.readFileSync(f, "utf8"));
685
+ return 0;
686
+ } catch {
687
+ err(`no such node: ${id}`);
688
+ return 1;
689
+ }
690
+ }
691
+ // --json: parse the file, scan the loaded graph for inbound edges, and stamp the
692
+ // git blob SHA as `revision` — recomputed zero-dep (crypto builtin), byte-
693
+ // identical to the server's value for the same content (norm-spor-cli-mode-parity).
694
+ let raw;
658
695
  try {
659
- out(fs.readFileSync(f, "utf8"));
660
- return 0;
696
+ raw = fs.readFileSync(f);
661
697
  } catch {
662
698
  err(`no such node: ${id}`);
663
699
  return 1;
664
700
  }
701
+ const graphLib = require(path.join(ROOT, "lib", "graph.js"));
702
+ const node = graphLib.parseFrontmatter(raw.toString("utf8"), `${id}.md`);
703
+ const inbound = inboundEdges(graphLib.loadGraph(nodesDir), node.id);
704
+ out(JSON.stringify(getNodeJson(node, inbound, gitBlobSha(raw)), null, 2));
705
+ return 0;
706
+ }
707
+
708
+ // The `spor get --json` shape (issue-spor-cli-get-missing-json-flag): one
709
+ // structured object so scripts stop scraping frontmatter. Built from a node
710
+ // parsed by the SAME lib/graph parser in both modes (norm-spor-cli-mode-parity).
711
+ // Frontmatter = lib/query.js's shared cleanNode projection (drop the load-time
712
+ // `file` artifact + the parser's empty pin/exclude registers) minus what we
713
+ // surface separately (edges, body); the synthesized `project` (from repo:) is
714
+ // kept — every consumer keys on it and the server's frontmatter carries it too.
715
+ function getNodeJson(node, inbound, revision) {
716
+ const { cleanNode } = require(path.join(ROOT, "lib", "query.js"));
717
+ const frontmatter = cleanNode(node);
718
+ delete frontmatter.edges;
719
+ delete frontmatter.body;
720
+ return {
721
+ id: node.id,
722
+ frontmatter,
723
+ body: node.body || "",
724
+ edges: { outbound: node.edges || [], inbound: inbound || [] },
725
+ revision: revision ?? null,
726
+ };
727
+ }
728
+
729
+ // Inbound edges to a node from a loaded graph — every other node's out-edge that
730
+ // points here, as {from, type}. Reuses lib/query.js's --to walk (a node only
731
+ // stores its own out-edges, so inbound is a whole-graph scan).
732
+ function inboundEdges(graph, id) {
733
+ const { queryGraph } = require(path.join(ROOT, "lib", "query.js"));
734
+ return queryGraph(graph, { edges: true, to: id }).edges.map((e) => ({ from: e.from, type: e.type }));
735
+ }
736
+
737
+ // The git blob SHA of a node's bytes — the value the server stores as `revision`
738
+ // (API.md §0) and an update sends back. Pure Node (crypto builtin, zero-dep):
739
+ // sha1 of "blob <len>\0<bytes>", exactly `git hash-object`, so a local --json
740
+ // revision is byte-identical to the server's for the same content (verified
741
+ // against the live graph).
742
+ function gitBlobSha(buf) {
743
+ const h = require("crypto").createHash("sha1");
744
+ h.update(`blob ${buf.length}\0`);
745
+ h.update(buf);
746
+ return h.digest("hex");
665
747
  }
666
748
 
667
749
  // --- spor blame / commits: commit-sha -> nodes reverse lookup ---------------
@@ -1812,6 +1894,25 @@ function today() {
1812
1894
  return new Date().toISOString().slice(0, 10);
1813
1895
  }
1814
1896
 
1897
+ // An id round-trips through the frontmatter edge parser only if it is all [\w-]:
1898
+ // lib/kernel/graph.js parseFrontmatter captures an edge `to:` as [\w-]+, so any
1899
+ // other character makes the whole "- {type: X, to: Y}" line fail to match and
1900
+ // silently vanish on read, breaking the reference without a trace. Edges we write
1901
+ // locally (spor add --during/--blocks, spor ask --mention) must reject such ids
1902
+ // rather than drop them — remote mode already rejects them server-side
1903
+ // (issue-spor-local-add-ask-project-normalization-edge-validation).
1904
+ const EDGE_ID_RE = /^[\w-]+$/;
1905
+ // First [flag, id] pair whose id won't round-trip, or null when all are clean.
1906
+ function firstBadEdgeId(pairs) {
1907
+ for (const [flag, id] of pairs) {
1908
+ if (id != null && !EDGE_ID_RE.test(id)) return { flag, id };
1909
+ }
1910
+ return null;
1911
+ }
1912
+ function edgeIdErr(bad) {
1913
+ return `invalid ${bad.flag} id "${bad.id}" — node ids may contain only letters, digits, '-' and '_'; this edge would be silently dropped on read.`;
1914
+ }
1915
+
1815
1916
  // Spool a failed remote capture body to the SHARED outbox
1816
1917
  // (graphHome/outbox/*.capture.json) — the exact queue session-start's
1817
1918
  // drain-outbox engine replays to /v1/capture. The body is written VERBATIM so the
@@ -1907,6 +2008,25 @@ async function cmdAdd(cfg, { values, positionals }) {
1907
2008
  err(`no graph at ${nodesDir} — run 'spor init' first`);
1908
2009
  return 1;
1909
2010
  }
2011
+ // Reject an edge target id that wouldn't round-trip through the frontmatter
2012
+ // parser before we write the node — otherwise the edge silently vanishes on
2013
+ // the next read (issue-spor-local-add-ask-project-normalization-edge-validation).
2014
+ const badEdge = firstBadEdgeId([["--during", during], ["--blocks", blocks]]);
2015
+ if (badEdge) {
2016
+ err(edgeIdErr(badEdge));
2017
+ return 1;
2018
+ }
2019
+ // Normalize an explicit --project the same way an inferred slug already is
2020
+ // (safeSlug -> projectSlug), so `--project My_Repo` files the node under
2021
+ // `my-repo` instead of stamping the verbatim, non-canonical value the server
2022
+ // would have rejected. safeSlug() is already normalized, so this only bites the
2023
+ // explicit flag; a flag with no slug characters is a hard error, not a silent
2024
+ // empty stamp (issue-spor-local-add-ask-project-normalization-edge-validation).
2025
+ const localProject = values.project ? u.slugify(values.project) : project;
2026
+ if (values.project && !localProject) {
2027
+ err(`invalid --project "${values.project}" — it has no slug characters (expected ^[a-z0-9][a-z0-9-]*$).`);
2028
+ return 1;
2029
+ }
1910
2030
  const type = values.type || "task";
1911
2031
  const title = values.title || prose.split(/\s+/).slice(0, 10).join(" ");
1912
2032
  const summary = prose.length > 500 ? prose.slice(0, 497) + "..." : prose;
@@ -1935,7 +2055,7 @@ async function cmdAdd(cfg, { values, positionals }) {
1935
2055
  if (blocks) edgeLines.push(` - {type: blocks, to: ${blocks}}`);
1936
2056
  const edgesBlock = edgeLines.length ? `edges:\n${edgeLines.join("\n")}\n` : "";
1937
2057
  const neededByLine = neededBy ? `needed_by: ${neededBy}\n` : "";
1938
- const md = `---\nid: ${id}\ntype: ${type}\nrepo: ${project}\ntitle: ${title.replace(/\n/g, " ")}\nsummary: ${summary.replace(/\n/g, " ")}\n${neededByLine}${edgesBlock}date: ${today()}\n---\n\n${prose}\n`;
2058
+ const md = `---\nid: ${id}\ntype: ${type}\nrepo: ${localProject}\ntitle: ${title.replace(/\n/g, " ")}\nsummary: ${summary.replace(/\n/g, " ")}\n${neededByLine}${edgesBlock}date: ${today()}\n---\n\n${prose}\n`;
1939
2059
  // validate before writing (parse, then the same rules lib/validate enforces)
1940
2060
  let node;
1941
2061
  try {
@@ -2024,7 +2144,24 @@ async function cmdAsk(cfg, { values, positionals }) {
2024
2144
  err(`no graph at ${nodesDir} — run 'spor init' first`);
2025
2145
  return 1;
2026
2146
  }
2027
- const slug = project || safeSlug();
2147
+ // Reject a --mention id that wouldn't round-trip through the frontmatter parser
2148
+ // before writing, so the mentions edge can't silently vanish on read
2149
+ // (issue-spor-local-add-ask-project-normalization-edge-validation).
2150
+ const badMention = firstBadEdgeId(mentions.map((m) => ["--mention", m]));
2151
+ if (badMention) {
2152
+ err(edgeIdErr(badMention));
2153
+ return 1;
2154
+ }
2155
+ // Normalize an explicit --project to the canonical slug the cwd fallback
2156
+ // already is (safeSlug -> projectSlug), so `--project My_Repo` stamps `my-repo`
2157
+ // instead of a verbatim, non-canonical value the server would have rejected; a
2158
+ // flag with no slug characters is a hard error, not a silent empty stamp
2159
+ // (issue-spor-local-add-ask-project-normalization-edge-validation).
2160
+ if (project && !u.slugify(project)) {
2161
+ err(`invalid --project "${project}" — it has no slug characters (expected ^[a-z0-9][a-z0-9-]*$).`);
2162
+ return 1;
2163
+ }
2164
+ const slug = project ? u.slugify(project) : safeSlug();
2028
2165
  const titleText = title || text.split(/\s+/).slice(0, 10).join(" ");
2029
2166
  const summary = text.length > 500 ? text.slice(0, 497) + "..." : text;
2030
2167
 
@@ -4426,12 +4563,12 @@ function cmdMigrate(cfg, { positionals }) {
4426
4563
  const dirty = (git(home, ["status", "--porcelain"]).stdout || "").trim();
4427
4564
  const hasCommit = git(home, ["rev-parse", "--verify", "-q", "HEAD"]).status === 0;
4428
4565
  if (dirty || !hasCommit) {
4429
- let c = git(home, ["commit", "-q", "-m", "spor: graph snapshot"]);
4566
+ let c = git(home, [...u.NO_GPGSIGN, "commit", "-q", "-m", "spor: graph snapshot"]);
4430
4567
  // No git identity configured in this environment — fall back so the
4431
4568
  // housekeeping commit still lands. The user's own identity is preferred
4432
4569
  // whenever git has one; this only fires when it has none.
4433
4570
  if (c.status !== 0 && /identity|user\.(email|name)|empty ident/i.test(c.stderr || "")) {
4434
- c = git(home, ["-c", "user.email=spor@localhost", "-c", "user.name=spor", "commit", "-q", "-m", "spor: graph snapshot"]);
4571
+ c = git(home, [...u.NO_GPGSIGN, "-c", "user.email=spor@localhost", "-c", "user.name=spor", "commit", "-q", "-m", "spor: graph snapshot"]);
4435
4572
  }
4436
4573
  if (c.status !== 0) {
4437
4574
  err(`could not commit the graph: ${(c.stderr || "").trim() || "nothing to commit"}`);
@@ -7513,10 +7650,20 @@ const COMMANDS = {
7513
7650
  run: (cfg, args) => cmdNext(cfg, args),
7514
7651
  },
7515
7652
  get: {
7516
- group: "Graph", parse: "strict", args: "<id>", options: {},
7653
+ group: "Graph", parse: "strict", args: "<id> [--json]",
7654
+ options: {
7655
+ json: { type: "boolean", desc: "structured JSON: frontmatter, edges (inbound+outbound), body, revision" },
7656
+ },
7517
7657
  summary: "a node by id (local: file; remote: /v1/nodes/<id>)",
7518
- help: "Print one node's raw markdown by id. Remote mode reads /v1/nodes/<id>; local\nmode reads the node file. A missing node exits 1.",
7519
- examples: ["spor get dec-cc-zero-dep-client"],
7658
+ help:
7659
+ "Print one node's raw markdown by id. Remote mode reads /v1/nodes/<id>; local\n" +
7660
+ "mode reads the node file. A missing node exits 1.\n\n" +
7661
+ "--json emits a structured object — {id, frontmatter, body, edges:{outbound,\n" +
7662
+ "inbound}, revision} — so scripts and tooling stop scraping markdown\n" +
7663
+ "frontmatter. `revision` is the git blob SHA an update sends; inbound edges are\n" +
7664
+ "gathered by scanning the whole graph (remote fetches GET /v1/export), so --json\n" +
7665
+ "is heavier than the plain read. Mode-symmetric (norm-spor-cli-mode-parity).",
7666
+ examples: ["spor get dec-cc-zero-dep-client", "spor get dec-cc-zero-dep-client --json"],
7520
7667
  run: (cfg, p) => cmdGet(cfg, p),
7521
7668
  },
7522
7669
  blame: {
@@ -8339,7 +8486,7 @@ async function main() {
8339
8486
  // Expose the pure helpers for unit tests (the version-check logic has no I/O),
8340
8487
  // and only run the CLI when invoked directly — requiring this file must not
8341
8488
  // kick off main() and call process.exit under the test runner.
8342
- module.exports = { nodeFloor, nodeRuntimeCheck, verCmp, sporConnectorBound, COMMANDS, resolveVerb };
8489
+ module.exports = { nodeFloor, nodeRuntimeCheck, verCmp, sporConnectorBound, COMMANDS, resolveVerb, getNodeJson, gitBlobSha };
8343
8490
 
8344
8491
  if (require.main === module) {
8345
8492
  main()
package/lib/config.js CHANGED
@@ -118,6 +118,16 @@ function isPlainObject(v) {
118
118
  return v != null && typeof v === "object" && !Array.isArray(v);
119
119
  }
120
120
 
121
+ // True iff `p` is a regular file (follows symlinks). Fail-open: any stat error
122
+ // (absent, unreadable, EPERM) reads as "not a file" rather than throwing.
123
+ function isFile(p) {
124
+ try {
125
+ return fs.statSync(p).isFile();
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
121
131
  // Deep-merge src onto dst (mutates dst). Objects merge recursively; arrays and
122
132
  // scalars replace wholesale (a higher layer's list overrides, never appends).
123
133
  function deepMerge(dst, src) {
@@ -201,14 +211,24 @@ function repoConfigFiles(cwd) {
201
211
  // (task-spor-plugin-opt-in-default) — it marks a repo that `spor enable`,
202
212
  // `spor link`, or `spor dispatch --backfill` has touched. Mirrors the
203
213
  // nearest-ancestor walks used for `.spor.json` config and the `.spor` graph
204
- // binding. Fail-open: existsSync never throws, so an unreadable level reads as
214
+ // binding.
215
+ //
216
+ // A marker is only ever a regular FILE: the flat `.spor` identity marker is
217
+ // `key: value` text (what repoMarkerGraph/repoMarkerOrg/projectSlug read) and
218
+ // `.spor.json` is a JSON config file. The default LOCAL graph home is itself a
219
+ // DIRECTORY named `.spor` (`~/.spor`), so a bare existsSync would treat the
220
+ // graph home as a repo marker and falsely opt-in EVERY markerless repo nested
221
+ // under it (issue-spor-home-dir-marker-opt-in-leak). Requiring a regular file
222
+ // excludes the graph-home directory (and any other `.spor` directory, e.g. a
223
+ // `graph:` binding target) while still matching every real opt-in marker.
224
+ // Fail-open: isFile() swallows stat errors, so an unreadable level reads as
205
225
  // absent rather than erroring.
206
226
  function repoMarkerPresent(cwd) {
207
227
  const seen = new Set();
208
228
  for (let dir = cwd || ""; dir; dir = path.dirname(dir)) {
209
229
  if (seen.has(dir)) break;
210
230
  seen.add(dir);
211
- if (fs.existsSync(path.join(dir, ".spor")) || fs.existsSync(path.join(dir, ".spor.json"))) return true;
231
+ if (isFile(path.join(dir, ".spor")) || isFile(path.join(dir, ".spor.json"))) return true;
212
232
  if (dir === path.dirname(dir)) break; // hit fs root
213
233
  }
214
234
  return false;
@@ -31,6 +31,11 @@
31
31
  const TERMINAL = new Set([
32
32
  "done", "resolved", "superseded", "rejected", "closed", "completed", "abandoned", "answered",
33
33
  "merged", // a triaged capture-pending: its content now lives in proper nodes
34
+ "dismissed", // a gardener finding deliberately dismissed: drops from the queue
35
+ // AND stays sticky — the server gardener's re-open branch only
36
+ // revives `resolved` findings, never a dismissal
37
+ // (issue-cc-dismissed-status-not-terminal,
38
+ // dec-spor-gardener-reopen-warranted-resolved-finding)
34
39
  ]);
35
40
  // Fallback resolving partition, used ONLY when a graph carries no registry
36
41
  // (hand-built test graphs). The live partition is read off graph.registry
package/lib/query.js CHANGED
@@ -123,7 +123,22 @@ function lookupCommit(graph, sha, repo = null) {
123
123
  return out;
124
124
  }
125
125
 
126
- module.exports = { queryGraph, fieldMatches, lookupCommit };
126
+ // Project a loaded node for JSON output: drop the parser's load-time `file`
127
+ // artifact, and drop pin/exclude when empty — the regex parser initializes both
128
+ // to [] on every node, so they are noise unless a briefing/correction actually
129
+ // populated them (a non-empty list is kept). Deletes from a shallow copy so the
130
+ // original key order is preserved. Shared by `spor query --json` (the CLI below)
131
+ // and `spor get --json` (bin/spor.js getNodeJson) so the projection rule lives in
132
+ // ONE place.
133
+ function cleanNode(n) {
134
+ const out = { ...n };
135
+ delete out.file;
136
+ if (Array.isArray(out.pin) && !out.pin.length) delete out.pin;
137
+ if (Array.isArray(out.exclude) && !out.exclude.length) delete out.exclude;
138
+ return out;
139
+ }
140
+
141
+ module.exports = { queryGraph, fieldMatches, lookupCommit, cleanNode };
127
142
 
128
143
  // ---------- CLI (local mode / debugging) ----------
129
144
 
@@ -175,19 +190,6 @@ if (require.main === module) {
175
190
  to: has("to") ? opt("to", null) : null,
176
191
  });
177
192
 
178
- // Project a loaded node for JSON output: drop the parser's load-time `file`
179
- // artifact, and drop pin/exclude when empty — the regex parser initializes
180
- // both to [] on every node, so they are noise unless a briefing/correction
181
- // actually populated them (a non-empty list is kept). Deletes from a shallow
182
- // copy so the original key order is preserved.
183
- const cleanNode = (n) => {
184
- const out = { ...n };
185
- delete out.file;
186
- if (Array.isArray(out.pin) && !out.pin.length) delete out.pin;
187
- if (Array.isArray(out.exclude) && !out.exclude.length) delete out.exclude;
188
- return out;
189
- };
190
-
191
193
  if (has("json")) {
192
194
  if (r.edges) process.stdout.write(JSON.stringify(r.edges, null, 2) + "\n");
193
195
  else process.stdout.write(JSON.stringify(r.nodes.map(cleanNode), null, 2) + "\n");
@@ -15,9 +15,9 @@ registry default (QUEUE.md §2). A `type: schema` node in the graph with
15
15
  `transitions()` (2026.06.13.1): a capture-pending node is born status-less
16
16
  (live, awaiting triage) and may close only as `merged` (its content now lives
17
17
  in proper node(s)) or `rejected` (no durable fact). Earlier triage drifted
18
- across `dismissed`/`resolved`/`closed`; `dismissed` in particular is not
19
- terminal to the queue kernel (lib/kernel/resolution.js), so those captures
20
- looked triaged yet kept ranking live
18
+ across `dismissed`/`resolved`/`closed` none of which is a capture verdict:
19
+ `dismissed` is the gardener's sticky disposition for findings (kernel-terminal,
20
+ lib/kernel/resolution.js), not a triage outcome for a raw capture
21
21
  (issue-cc-dismissed-status-not-terminal). The gate forbids the drift at write
22
22
  time rather than hardcoding a value set; chosen over a declarative status enum
23
23
  because the gate is write-time on the current→proposed transition, so it never
@@ -52,8 +52,9 @@ Backward-readable: write-time only, no node-shape change, no upgrade chain.
52
52
  // duplicate/elaboration and leaves the queue.
53
53
  // rejected — no durable fact worth keeping.
54
54
  // Any other status (the historical dismissed/resolved/closed drift) is denied:
55
- // dismissed was never terminal to the queue kernel (lib/kernel/resolution.js),
56
- // so it produced captures that looked triaged yet kept ranking live
55
+ // none is a capture verdict — `dismissed` is the gardener's sticky disposition
56
+ // for findings (kernel-terminal in lib/kernel/resolution.js), not a triage
57
+ // outcome for a raw capture
57
58
  // (issue-cc-dismissed-status-not-terminal, dec-cc-status-enforcement-via-transitions).
58
59
  // Defining the allowed set ONCE is what makes the create path and the update
59
60
  // path AGREE (issue-spor-node-create-bypasses-status-vocabulary): the membership
@@ -63,8 +64,8 @@ function statusReason(next) {
63
64
  return "invalid capture-pending status '" + next + "': a capture closes only " +
64
65
  "as 'merged' (its content now lives in proper node(s) — write those nodes " +
65
66
  "first, then set merged) or 'rejected' (no durable fact worth keeping). " +
66
- "Do not use dismissed/resolved/closed — they are not terminal to the queue " +
67
- "and leave the capture ranking live (dec-cc-status-enforcement-via-transitions).";
67
+ "Do not use dismissed/resolved/closed — they are not capture verdicts " +
68
+ "(dec-cc-status-enforcement-via-transitions).";
68
69
  }
69
70
 
70
71
  // validate(node) — the door, runs on EVERY write (create AND update) in the
@@ -34,7 +34,7 @@ write-time only, no node-shape change, no upgrade chain.
34
34
  `transitions()` (2026.06.14.1): two write-time gates. (1) Status is
35
35
  constrained to the issue vocabulary (`open`/`active`/`resolved`, or none =
36
36
  live) so the queue-terminal value (`resolved`) is not shadowed by synonyms —
37
- the same class of drift that left captures ranking live under a non-terminal
37
+ the same class of drift that left captures ranking live under a then-non-terminal
38
38
  `dismissed` (dec-cc-status-enforcement-via-transitions). (2) `resolved`
39
39
  additionally requires a durable outcome ON THE GRAPH: a live inbound `resolves`
40
40
  edge from a `decision` or `artifact` node, read off `view.resolvers`
@@ -30,7 +30,7 @@ Backward-readable: write-time only, no stored-shape change, no upgrade chain.
30
30
  `transitions()` (2026.06.14.1): two write-time gates. (1) Status is
31
31
  constrained to the task vocabulary (`open`/`active`/`done`/`abandoned`, or
32
32
  none = live) so the queue-terminal value (`done`) is not shadowed by synonyms
33
- — the same class of drift that left captures ranking live under a non-terminal
33
+ — the same class of drift that left captures ranking live under a then-non-terminal
34
34
  `dismissed` (dec-cc-status-enforcement-via-transitions). (2) Completion
35
35
  (`done`) additionally requires a durable outcome ON THE GRAPH: a live inbound
36
36
  `resolves` edge from a `decision` or `artifact` node, read off `view.resolvers`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",
@@ -415,7 +415,7 @@ async function distill(input) {
415
415
  } else {
416
416
  const add = u.git(graph, ["add", "nodes/"]);
417
417
  const commit =
418
- add !== null ? u.git(graph, ["commit", "-qm", `distill: session ${session} (${slug})`]) : null;
418
+ add !== null ? u.git(graph, [...u.NO_GPGSIGN, "commit", "-qm", `distill: session ${session} (${slug})`]) : null;
419
419
  if (add === null || commit === null) log("graph commit failed");
420
420
  }
421
421
  }
@@ -191,6 +191,21 @@ function inferenceRoot(cwd) {
191
191
  return top;
192
192
  }
193
193
 
194
+ // Normalize a raw string to the canonical project slug (the server's SLUG_RE,
195
+ // ^[a-z0-9][a-z0-9-]*$): lowercased, runs of non-alphanumerics collapsed to a
196
+ // single '-', and leading/trailing '-' trimmed. This is the ONE normalization
197
+ // projectSlug() applies to a basename, factored out so a hand-passed slug — an
198
+ // explicit `spor add --project My_Repo` — gets the SAME treatment as an inferred
199
+ // one instead of being stamped verbatim and mis-filing the node
200
+ // (issue-spor-local-add-ask-project-normalization-edge-validation). Empty when
201
+ // the input carries no alphanumerics (the caller decides how to handle that).
202
+ function slugify(raw) {
203
+ return String(raw == null ? "" : raw)
204
+ .toLowerCase()
205
+ .replace(/[^a-z0-9]+/g, "-")
206
+ .replace(/^-+|-+$/g, "");
207
+ }
208
+
194
209
  // Project slug (see CLAUDE.md): basename of the git root, normalized to
195
210
  // kebab-case. Identity for names that are already kebab-case. A committed
196
211
  // `.spor` marker file (`project: <id>`) beats all inference — it survives
@@ -220,11 +235,7 @@ function projectSlug(cwd, fallback = "project") {
220
235
  const rootHit = readMarker(root);
221
236
  if (rootHit) return rootHit;
222
237
  }
223
- const slug = path
224
- .basename(root || cwd || "")
225
- .toLowerCase()
226
- .replace(/[^a-z0-9]+/g, "-")
227
- .replace(/^-+|-+$/g, "");
238
+ const slug = slugify(path.basename(root || cwd || ""));
228
239
  return slug || fallback;
229
240
  }
230
241
 
@@ -379,6 +390,15 @@ function journalLoadMs(graph, session, engine, loadMs, extra = {}) {
379
390
  }
380
391
  }
381
392
 
393
+ // Global git flags that force commit signing OFF, spread in BEFORE the `commit`
394
+ // subcommand at every automated-commit site (graph snapshots, the SessionEnd
395
+ // distiller, `spor init`/`migrate`). A user with a global commit.gpgsign=true
396
+ // but no usable signing key/agent would otherwise have these housekeeping
397
+ // commits fail SILENTLY — the workflow reports success but nothing lands in git
398
+ // history (issue-spor-local-commit-gpgsign-silent-failure). The graph home is
399
+ // machine-local plumbing, so signing it buys nothing and only risks that failure.
400
+ const NO_GPGSIGN = ["-c", "commit.gpgsign=false"];
401
+
382
402
  function git(cwd, args, opts = {}) {
383
403
  try {
384
404
  return execFileSync("git", ["-C", cwd, ...args], {
@@ -1054,11 +1074,13 @@ module.exports = {
1054
1074
  wordCount,
1055
1075
  stripTrailingNewlines,
1056
1076
  inferenceRoot,
1077
+ slugify,
1057
1078
  projectSlug,
1058
1079
  projectGrouping,
1059
1080
  matchBriefs,
1060
1081
  repoFingerprints,
1061
1082
  git,
1083
+ NO_GPGSIGN,
1062
1084
  graphInsideCodeRepo,
1063
1085
  ensureDir,
1064
1086
  spoolStats,
@@ -111,8 +111,8 @@ one plain sentence of "why this is first" is enough. Then:
111
111
  with `set_status`: `merged` when its content now lives in the node(s) you
112
112
  wrote, or `rejected` when there was no durable fact. Those are the only two
113
113
  terminal statuses the schema's `transitions()` gate accepts — `dismissed`/
114
- `resolved`/`closed` are rejected at write time because they are not terminal
115
- to the queue and leave the capture ranking live
114
+ `resolved`/`closed` are rejected at write time because they are not capture
115
+ verdicts (`dismissed` is the gardener's sticky disposition for findings)
116
116
  (dec-cc-status-enforcement-via-transitions).
117
117
  4. **Item with nothing actionable until a date** (waiting on a measurement
118
118
  window, a renewal, an external milestone) → set `wake: YYYY-MM-DD` on the
@@ -75,8 +75,8 @@ applied (body_full): would push body over the 8192B limit"* — those are repeat
75
75
  re-captures that bounced because the target node is already at its size cap, so
76
76
  the fact is *already on the graph*. Read one, confirm the target node holds it,
77
77
  and `merged` the whole cluster. (`dismissed`/`resolved`/`closed` are rejected at
78
- write time — they leave the capture ranking live; only `merged`/`rejected` are
79
- terminal here.)
78
+ write time — they are not capture verdicts; only `merged`/`rejected` are valid
79
+ here.)
80
80
 
81
81
  ## 3. Duplicates → consolidate with a `supersedes` edge
82
82