@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.
- package/.claude-plugin/plugin.json +1 -1
- package/GRAPH.md +15 -0
- package/bin/spor.js +30 -18
- package/lib/kernel/graph.js +70 -6
- package/lib/seed/schema-norm.md +24 -0
- package/lib/seed/schema-repo.md +13 -0
- package/package.json +1 -1
- package/skills/correct/SKILL.md +6 -0
- package/skills/spor/references/authoring-schemas.md +4 -1
- package/skills/spor/references/concepts.md +16 -4
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"name": "spor",
|
|
3
3
|
"displayName": "Spor Context Compiler",
|
|
4
4
|
"description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.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
|
|
929
|
-
// added in claude.ai surfaces in Claude Code as the mcp__…_Spor__*
|
|
930
|
-
// (art-cc-spor-connector-dual-host), i.e. a SECOND live write surface
|
|
931
|
-
// the local file graph.
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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; //
|
|
960
|
+
return false; // claude missing, spawn error, or unparseable => assume none
|
|
949
961
|
}
|
|
950
962
|
}
|
|
951
963
|
|
package/lib/kernel/graph.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
610
|
-
|
|
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}'`);
|
package/lib/seed/schema-norm.md
CHANGED
|
@@ -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",
|
package/lib/seed/schema-repo.md
CHANGED
|
@@ -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
|
+
"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",
|
package/skills/correct/SKILL.md
CHANGED
|
@@ -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
|
-
`
|
|
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.
|