@sporhq/spor 0.3.1 → 0.4.1

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.3.1",
5
+ "version": "0.4.1",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/GRAPH.md CHANGED
@@ -336,6 +336,21 @@ their original order), so the briefing degrades by relevance rather than by the
336
336
  downstream 7KB session-start body truncation. A project-blind compile keeps
337
337
  every norm, exactly as before.
338
338
 
339
+ Project scope resolves to the whole home-project **grouping union**, so a
340
+ grouping that deliberately spans heterogeneous repos (a terraform IaC repo, a
341
+ Go service, a Python service) would still cross-pollinate norms. A norm may
342
+ **narrow** its ride-along with optional flat `applies_to_*` selectors, matched
343
+ against the session's OWN repo (task-cc-norm-ride-along-repo-tag-scope):
344
+ `applies_to_tags: [python]` (∩ the session repo node's `tags`, schema-repo),
345
+ `applies_to_repos: [repo-x]` (the session repo), `applies_to_projects:
346
+ [proj-y]` (a grouping the session repo belongs to). Matching is OR across axes,
347
+ ANY within an axis — deliberately unlike the policy layer's `governs`
348
+ (AND-across-axes). A norm that declares any `applies_to_*` and matches none is
349
+ **excluded** (strict, including in a repo with no `tags` — repo tagging is the
350
+ opt-in that turns scoped norms on); a norm with none keeps the project-scoped
351
+ behavior above, so a graph using no `applies_to_*` is byte-identical
352
+ (norm-cc-byte-identical-refactor).
353
+
339
354
  Because a norm rides along with no relevance gate and the team trust model lets
340
355
  every writer author one, the briefing renderer treats norm bodies as an
341
356
  **injection surface** (issue-cc-norm-always-on-injection): each is quoted as
package/bin/spor.js CHANGED
@@ -925,27 +925,39 @@ function claudePluginInfo() {
925
925
  return p ? { version: p.version, scope: p.scope, enabled: p.enabled, installPath: p.installPath } : null;
926
926
  }
927
927
 
928
- // Best-effort: is a claude.ai Spor MCP connector bound on this box? A connector
929
- // added in claude.ai surfaces in Claude Code as the mcp__…_Spor__* tools
930
- // (art-cc-spor-connector-dual-host), i.e. a SECOND live write surface alongside
931
- // the local file graph. Claude Code records connected claude.ai connectors in
932
- // ~/.claude.json's `claudeAiMcpEverConnected` array (entries like
933
- // "claude.ai Spor"); we key the detection on a Spor-named entry there (matching
934
- // the pre-rename "Substrate" name too the connector predates the rename and
935
- // the array keeps historical entries). This is the only discoverable signal a
936
- // plain Claude Code box exposes: there is no per-session "currently active"
937
- // manifest. FAIL-OPEN by contract — any missing/unreadable/unparseable file
938
- // returns false so `spor status` never emits a false split-brain warning or
939
- // crashes. SPOR_FAKE_CLAUDE_JSON overrides the path for tests.
928
+ // Best-effort: is a claude.ai Spor MCP connector CURRENTLY bound on this box? A
929
+ // connector added in claude.ai surfaces in Claude Code as the mcp__…_Spor__*
930
+ // tools (art-cc-spor-connector-dual-host), i.e. a SECOND live write surface
931
+ // alongside the local file graph. We read the LIVE set from `claude mcp list`
932
+ // (mirroring claudePluginInfo's spawn) and look for a Spor-named connector —
933
+ // matching the pre-rename "Substrate" name too. We deliberately do NOT key on
934
+ // ~/.claude.json's `claudeAiMcpEverConnected`: that array is a sticky historical
935
+ // "ever connected" list that never clears when a connector is disabled or
936
+ // removed, so it warned forever after the user unbound the connector
937
+ // (issue-spor-status-split-brain-warning-false-positive). FAIL-OPEN by contract:
938
+ // claude absent / nonzero exit / timeout / empty output all return false, so
939
+ // `spor status` never emits a false split-brain warning or hangs. The health
940
+ // status (Connected / Needs authentication / Failed) is ignored — any current
941
+ // binding is a configured second write surface. SPOR_FAKE_MCP_LIST injects
942
+ // canned `claude mcp list` output for tests.
940
943
  function sporConnectorBound() {
941
944
  try {
942
- const p = process.env.SPOR_FAKE_CLAUDE_JSON || path.join(homeDir(), ".claude.json");
943
- const j = JSON.parse(fs.readFileSync(p, "utf8"));
944
- const ever = j && j.claudeAiMcpEverConnected;
945
- if (!Array.isArray(ever)) return false;
946
- return ever.some((name) => typeof name === "string" && /\bspor\b|\bsubstrate\b/i.test(name));
945
+ let text = process.env.SPOR_FAKE_MCP_LIST;
946
+ if (text == null) {
947
+ const cmd = claudeCmd();
948
+ if (cmd === "claude" && !hasCmd("claude")) return false;
949
+ const r = spawnSync(cmd, ["mcp", "list"], { encoding: "utf8", timeout: 8000 });
950
+ if (r.status !== 0 || !r.stdout) return false;
951
+ text = r.stdout;
952
+ }
953
+ // Each connector is a line like "claude.ai Spor: <url> - <status>". Match the
954
+ // NAME segment (before the first colon) only, so a "spor" in a URL or status
955
+ // can't trip it; \b keeps "Spotify"/"Supabase" from matching "spor".
956
+ return text
957
+ .split("\n")
958
+ .some((line) => /\bspor\b|\bsubstrate\b/i.test(line.split(":")[0] || ""));
947
959
  } catch {
948
- return false; // no file, unreadable, or malformed => assume no connector
960
+ return false; // claude missing, spawn error, or unparseable => assume none
949
961
  }
950
962
  }
951
963
 
@@ -89,13 +89,16 @@ const DEFAULT_MIN_SIM = 0.08;
89
89
  // Regex-based frontmatter parser (hard rule: no YAML library — zero deps).
90
90
  // Supports simple
91
91
  // key: value, YAML folded multi-line values (indented continuations),
92
- // pin:/exclude:/queue_mute:/commits:/slugs:/fingerprints:/roles: inline lists,
92
+ // pin:/exclude:/queue_mute:/commits:/slugs:/fingerprints:/roles:/tags:/
93
+ // applies_to_tags:/applies_to_repos:/applies_to_projects: inline lists,
93
94
  // and "- {type: X, to: Y}" edges. commits entries are repo-qualified shas
94
95
  // ("repo@sha", task-cc-commit-linking); slugs/fingerprints are the project
95
96
  // node's alias and repo-evidence registers (task-cc-project-identity-nodes);
96
97
  // roles is the person node's role-list register the org-defined policy layer's
97
- // quorum gate counts qualified approvals against (task-cc-policy-layer)
98
- // flat strings, not objects, by parser design.
98
+ // quorum gate counts qualified approvals against (task-cc-policy-layer); tags is
99
+ // the repo node's free-form label register, and applies_to_* are a norm's
100
+ // repo/tag-scope selectors matched against it (task-cc-norm-ride-along-repo-tag-
101
+ // scope) — flat strings, not objects, by parser design.
99
102
  function parseFrontmatter(raw, file) {
100
103
  const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
101
104
  if (!m) throw new Error(`no frontmatter in ${file}`);
@@ -103,7 +106,7 @@ function parseFrontmatter(raw, file) {
103
106
  const node = { edges: [], pin: [], exclude: [], body: body.trim(), file };
104
107
  let lastKey = null; // for YAML folded scalars (indented continuation lines)
105
108
  for (const line of fm.split("\n")) {
106
- const list = line.match(/^(pin|exclude|queue_mute|commits|slugs|fingerprints|roles):\s*\[([^\]]*)\]/);
109
+ const list = line.match(/^(pin|exclude|queue_mute|commits|slugs|fingerprints|roles|tags|applies_to_tags|applies_to_repos|applies_to_projects):\s*\[([^\]]*)\]/);
107
110
  if (list) { node[list[1]] = list[2].split(",").map((s) => s.trim()).filter(Boolean); lastKey = null; continue; }
108
111
  const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
109
112
  if (kv) {
@@ -606,8 +609,43 @@ function compile(graph, opts = {}) {
606
609
  // Project-blind compiles (sessionProject == null) keep every norm, so that
607
610
  // path stays byte-identical. Then cap the section to NORM_CAP, ordered by
608
611
  // topical relevance, so it degrades by relevance rather than byte truncation.
609
- const normRidesAlong = (n) =>
610
- sessionProject == null || !n.project || sameProject(n.id);
612
+ //
613
+ // Repo/tag-scoped ride-along (task-cc-norm-ride-along-repo-tag-scope): the
614
+ // project-scope above resolves to the GROUPING UNION (scopeFor expands the
615
+ // session repo slug to its whole home-project grouping), so a grouping that
616
+ // spans heterogeneous repos — a terraform IaC repo, a Go service, a Python
617
+ // service — cross-pollinates norms: a `uv` norm stamped to the Python repo
618
+ // rode along into the terraform and Go briefs. A norm may now NARROW itself
619
+ // with flat `applies_to_*` keys matched against the session's OWN repo (not
620
+ // the union): `applies_to_tags` ∩ the session repo node's `tags`,
621
+ // `applies_to_repos` = the session repo, `applies_to_projects` = a grouping
622
+ // the session repo belongs to. Semantics are OR across axes (inclusion union
623
+ // — "apply in these repos OR anything python-tagged"), ANY within an axis —
624
+ // deliberately UNLIKE the policy layer's `governs`, which is AND-across-axes
625
+ // (governance narrowing); do not "fix" this to match it. A norm that declares
626
+ // any `applies_to_*` and matches none is EXCLUDED (strict — the whole point
627
+ // is to stop the bleed), including in a repo with no `tags` at all, so repo
628
+ // tagging is the opt-in that turns scoped norms on. A norm with NO
629
+ // `applies_to_*` falls through to the project-scope behavior above unchanged,
630
+ // so a graph that uses no `applies_to_*` is byte-identical
631
+ // (norm-cc-byte-identical-refactor).
632
+ // Array-safe reads: a malformed scalar (e.g. `applies_to_tags: python` with
633
+ // no brackets parses to a string) is treated as ABSENT, so the norm falls
634
+ // through to project-scope rather than throwing on the un-wrapped kernel path
635
+ // — the mistake is surfaced as a validateGraphFiles warning, not a crash.
636
+ const arr = (v) => (Array.isArray(v) ? v : []);
637
+ const sessionRepoTags = new Set(arr(nodes[sessionProject]?.tags));
638
+ const hasAppliesTo = (n) =>
639
+ arr(n.applies_to_tags).length || arr(n.applies_to_repos).length || arr(n.applies_to_projects).length;
640
+ const appliesToHit = (n) =>
641
+ arr(n.applies_to_tags).some((t) => sessionRepoTags.has(t)) ||
642
+ arr(n.applies_to_repos).some((r) => resolveProject(graph, r) === sessionProject) ||
643
+ arr(n.applies_to_projects).some((p) => scopeFor(graph, p)?.has(sessionProject));
644
+ const normRidesAlong = (n) => {
645
+ if (sessionProject == null) return true; // project-blind: unchanged
646
+ if (hasAppliesTo(n)) return appliesToHit(n); // explicit scope wins
647
+ return !n.project || sameProject(n.id); // current project-scope
648
+ };
611
649
  const normCandidates = Object.values(nodes).filter((n) =>
612
650
  reg.isAlwaysOn(n.type) && normRidesAlong(n) &&
613
651
  !structuralSet.has(n.id) && !contentSet.has(n.id) && !pinned.has(n.id) && !excluded.has(n.id));
@@ -937,6 +975,32 @@ function validateGraphFiles(files, seedSchemas = []) {
937
975
  }
938
976
  }
939
977
 
978
+ // Ride-along scoping fields (task-cc-norm-ride-along-repo-tag-scope): `tags`
979
+ // is consulted only on repo nodes; `applies_to_*` only narrows always_on
980
+ // norms. The compiler treats a misplaced or malformed (non-list) value as
981
+ // absent — fail-safe but silent — so surface the mistake here. (The lenient
982
+ // validate parser stores inline lists as raw strings, so well-formedness is
983
+ // checked against the raw text, like the slug claim above.)
984
+ const RIDE_KEYS = ["applies_to_tags", "applies_to_repos", "applies_to_projects"];
985
+ for (const n of fileNodes) {
986
+ const raw = files[n.file] ?? "";
987
+ const listed = (key) => new RegExp(`^${key}:\\s*\\[[^\\]]*\\]`, "m").test(raw);
988
+ const present = (key) => new RegExp(`^${key}:`, "m").test(raw);
989
+ if (n.type && n.type !== "repo" && present("tags")) {
990
+ warnings.push(`${n.file}: tags on non-repo type '${n.type}' is ignored (only repo nodes carry ride-along tags)`);
991
+ } else if (n.type === "repo" && present("tags") && !listed("tags")) {
992
+ warnings.push(`${n.file}: tags must be an inline list '[a, b]' — a scalar value is ignored`);
993
+ }
994
+ for (const key of RIDE_KEYS) {
995
+ if (!present(key)) continue;
996
+ if (!listed(key)) {
997
+ warnings.push(`${n.file}: ${key} must be an inline list '[a, b]' — a scalar value is ignored`);
998
+ } else if (n.type && reg.isKnownType(n.type) && !reg.isAlwaysOn(n.type)) {
999
+ warnings.push(`${n.file}: ${key} on non-always_on type '${n.type}' has no effect (only always_on norms ride along)`);
1000
+ }
1001
+ }
1002
+ }
1003
+
940
1004
  for (const n of Object.values(nodes)) {
941
1005
  for (const e of n.edges) {
942
1006
  if (!reg.isKnownEdge(e.type)) warnings.push(`${n.file}: unknown edge type '${e.type}'`);
@@ -12,6 +12,30 @@ Seed schema for the `norm` node type, shipped with the plugin as a
12
12
  registry default (QUEUE.md §2). A `type: schema` node in the graph with
13
13
  `kind: node-schema` and the same `node_type` overrides this entry.
14
14
 
15
+ A norm's `always_on` ride-along is project-scoped by default
16
+ (issue-cc-norm-ride-along-unscoped-bloat), but "project" resolves to the whole
17
+ home-project GROUPING union — so a grouping that spans heterogeneous repos
18
+ (a terraform IaC repo, a Go service, a Python service) would still cross-
19
+ pollinate norms. A norm may NARROW its ride-along with optional flat
20
+ `applies_to_*` selectors, matched against the session's OWN repo
21
+ (task-cc-norm-ride-along-repo-tag-scope):
22
+
23
+ - `applies_to_tags: [python]` — rides along only into repos tagged `python`
24
+ (matched against the session repo node's `tags`, schema-repo). A `uv` norm
25
+ scoped this way stays out of a terraform or Go sibling.
26
+ - `applies_to_repos: [repo-my-svc]` — rides along only in the named repo(s)
27
+ (slug or `repo-` id; resolved through the alias map).
28
+ - `applies_to_projects: [proj-platform]` — rides along only when the session
29
+ repo belongs to the named project grouping.
30
+
31
+ Matching is **OR across axes** (inclusion union — "apply in these repos OR
32
+ anything python-tagged"), ANY within an axis — deliberately unlike the policy
33
+ layer's `governs`, which is AND-across-axes. A norm that declares any
34
+ `applies_to_*` and matches none is EXCLUDED (strict, including in a repo with
35
+ no tags). A norm with no `applies_to_*` keeps today's project-scoped ride-along
36
+ unchanged, so a graph that uses none is byte-identical. The selectors are flat
37
+ inline-list strings (the frontmatter parser takes no nested maps).
38
+
15
39
  ```json
16
40
  {
17
41
  "node_type": "norm",
@@ -35,6 +35,19 @@ inline-list fields:
35
35
  the alias as a queue item for human confirmation. An accumulating set,
36
36
  not a derivation rule — no single fingerprint survives every rewrite.
37
37
 
38
+ A repo may also carry an optional free-form `tags` register
39
+ (task-cc-norm-ride-along-repo-tag-scope):
40
+
41
+ - `tags: [python, backend]` — labels describing this repo, the matching key
42
+ for a norm's `applies_to_tags` ride-along selector (schema-norm). A norm
43
+ scoped `applies_to_tags: [python]` rides along into a session's briefing
44
+ only when this repo (the session's OWN repo) is tagged `python` — so a
45
+ `uv` norm stays out of a terraform or Go sibling under the same project
46
+ grouping. An untagged repo matches no tag-scoped norm (strict — repo
47
+ tagging is the opt-in that turns scoped norms on). Tags are flat strings,
48
+ consulted only on repo nodes; a graph with no `tags` behaves exactly as
49
+ before.
50
+
38
51
  An optional `status: archived` retires the whole repo at end-of-life
39
52
  (issue-cc-project-lifecycle-queue-pollution). One identity-level edit hides
40
53
  the repo's open tasks/questions from the decision queue for EVERY viewer —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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",
@@ -67,6 +67,12 @@ still used when `~/.spor` is absent).
67
67
  `~/.spor/nodes/`; if the knowledge isn't a node yet, create that node
68
68
  first per the plugin's GRAPH.md, then pin it)
69
69
  - an irrelevant/stale node was included → `exclude: [that-id]`
70
+ - exception: if the irrelevant node is a **norm bleeding in across repos**
71
+ (e.g. a `uv` norm showing up in a terraform or Go brief under the same
72
+ project), the durable fix is to scope the norm at its source — add
73
+ `applies_to_tags:`/`applies_to_repos:`/`applies_to_projects:` to the norm
74
+ node and `tags:` to the repos (see GRAPH.md / concepts.md) — not a
75
+ per-briefing `exclude` you'd have to repeat everywhere it bleeds.
70
76
  - emphasis/framing was wrong → free-text guidance in the body
71
77
 
72
78
  3. Write `~/.spor/nodes/corr-<target>-<n>.md` (n = next free integer):
@@ -36,7 +36,10 @@ functions**.
36
36
 
37
37
  - `node_type` / `prefix` — the type and its id prefix(es).
38
38
  - `queueable` — may appear in the decision queue.
39
- - `always_on` — rides along in every project-relevant compile (norms).
39
+ - `always_on` — rides along in every project-relevant compile (norms). A
40
+ norm INSTANCE can narrow that ride-along to specific repos with flat
41
+ `applies_to_tags:`/`applies_to_repos:`/`applies_to_projects:` frontmatter
42
+ keys (not schema flags) — see concepts.md "Ride-along flags".
40
43
  - `traversable: false` — excluded from lineage walks (briefings, corrections).
41
44
  - `capturable: false` — never produced by the capture/ingest path.
42
45
  - `status.non_resolving` — statuses that count as *not* resolving for the
@@ -18,14 +18,14 @@ the decision queue (QUEUE.md §4).
18
18
  | issue | `issue-` | a defect and its resolution lineage | status `open`/`active`/`resolved`; queueable |
19
19
  | incident | `inc-` | something that went wrong in operation | queueable |
20
20
  | artifact | `art-`, `spec-` | a document, spec, module, or build product | optional delivery status `in-review`/`approved`/`merged`/`released` |
21
- | norm | `norm-` | a standing convention or constraint | `always_on: true` — rides along in every project-relevant compile (capped to the topically relevant subset) |
21
+ | norm | `norm-` | a standing convention or constraint | `always_on: true` — rides along in every project-relevant compile (capped to the topically relevant subset); narrow it to specific repos with `applies_to_tags:`/`applies_to_repos:`/`applies_to_projects:` |
22
22
  | briefing | `brief-` | a compiled briefing (output of the system) | `traversable: false` (never walked) and `capturable: false` |
23
23
  | correction | `corr-` | a standing fix to a briefing (pin/exclude/guidance) | `traversable: false`; applied at every future compile of its target |
24
24
  | question | `question-` | a routed ask the graph couldn't answer | queueable; status `open`/`answered`; joins the queue until answered |
25
25
  | person | `person-` | an org member | anchor for `$viewer` binding and question routing |
26
26
  | capture-pending | `cap-` | raw captured text that fit no schema | born status-less; closes only as `merged` or `rejected` |
27
27
  | finding | `find-` | a gardener observation (stale anchor, cold work) | filed as a queue item |
28
- | repo | `repo-` | a durable git-repo identity | carries `slugs:` aliases + `fingerprints:`; heals renames at read time |
28
+ | repo | `repo-` | a durable git-repo identity | carries `slugs:` aliases + `fingerprints:`; heals renames at read time; optional `tags:` are the match key for a norm's `applies_to_tags` |
29
29
  | project | `proj-` | a stable grouping above repos | owns members via inbound `grouped-under` edges; owns no slugs/fingerprints itself |
30
30
  | workflow | `wf-` | a repeatable automation DAG | created `proposed`, inert until activated; queueable |
31
31
  | workflow-run | `run-` | one execution of a workflow | queueable when stuck; `capturable: false` |
@@ -64,6 +64,16 @@ are same-direction synonyms renamed at write time.
64
64
  - `traversable: false` (briefing, correction) — excluded from lineage walks.
65
65
  - `capturable: false` (briefing, workflow-run) — never produced by capture.
66
66
 
67
+ An `always_on` norm rides along project-wide by default, but that scope is the
68
+ whole home-project **grouping** — so under a project that spans heterogeneous
69
+ repos (a terraform repo, a Go service, a Python service) a `uv` norm would bleed
70
+ into all three. Narrow it with flat per-instance selectors on the norm node
71
+ (not the schema): `applies_to_tags: [python]` (matched against the session
72
+ repo's `tags:`), `applies_to_repos: [repo-x]`, `applies_to_projects: [proj-y]`.
73
+ A norm that declares any `applies_to_*` and matches none is **excluded** —
74
+ including in a repo with no `tags:` — so repo tagging is the opt-in. A norm with
75
+ none keeps the default project-wide ride-along.
76
+
67
77
  Don't invent edge variants. The automatic distiller sometimes emits forms like
68
78
  `related-to`/`supercedes`/`derives-from`; those normalize to the canonical
69
79
  spelling on write — they're the *only* accepted non-canonical forms. A genuinely
@@ -99,8 +109,10 @@ One fact per node; if you're writing "and also…", split it.
99
109
 
100
110
  Server-stamped fields you don't set by hand: `author`, `authored_via`. Other
101
111
  type-specific fields exist (`wake:` dormancy date; `commits:` linked git shas;
102
- `pin:`/`exclude:` on corrections; `slugs:`/`fingerprints:` on repo nodes;
103
- `roles:`/`queue_mute:` on person nodes) — see GRAPH.md for the complete list.
112
+ `pin:`/`exclude:` on corrections; `slugs:`/`fingerprints:`/`tags:` on repo nodes;
113
+ `applies_to_tags:`/`applies_to_repos:`/`applies_to_projects:` ride-along
114
+ selectors on norms; `roles:`/`queue_mute:` on person nodes) — see GRAPH.md for
115
+ the complete list.
104
116
 
105
117
  Validate any local node you write: `spor validate` (or
106
118
  `node lib/validate.js`), and fix what it flags.