@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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/spor.js +162 -15
- package/lib/config.js +22 -2
- package/lib/kernel/resolution.js +5 -0
- package/lib/query.js +16 -14
- package/lib/seed/schema-capture-pending.md +8 -7
- package/lib/seed/schema-issue.md +1 -1
- package/lib/seed/schema-task.md +1 -1
- package/package.json +1 -1
- package/scripts/engines/distill.js +1 -1
- package/scripts/engines/util.js +27 -5
- package/skills/next/SKILL.md +2 -2
- package/skills/triage/SKILL.md +2 -2
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
|
|
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>",
|
|
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:
|
|
7519
|
-
|
|
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.
|
|
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 (
|
|
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;
|
package/lib/kernel/resolution.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
//
|
|
56
|
-
//
|
|
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
|
|
67
|
-
"
|
|
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
|
package/lib/seed/schema-issue.md
CHANGED
|
@@ -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`
|
package/lib/seed/schema-task.md
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/scripts/engines/util.js
CHANGED
|
@@ -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,
|
package/skills/next/SKILL.md
CHANGED
|
@@ -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
|
|
115
|
-
|
|
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
|
package/skills/triage/SKILL.md
CHANGED
|
@@ -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
|
|
79
|
-
|
|
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
|
|