@sporhq/spor 0.18.6 → 0.19.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.18.6",
5
+ "version": "0.19.0",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spor",
3
- "version": "0.18.6",
3
+ "version": "0.19.0",
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
5
  "author": {
6
6
  "name": "Spor",
package/GRAPH.md CHANGED
@@ -444,6 +444,43 @@ opt-in that turns scoped norms on; set them with `spor repos tag <slug>
444
444
  keeps the project-scoped behavior above, so a graph using no `applies_to_*` is
445
445
  byte-identical (norm-cc-byte-identical-refactor).
446
446
 
447
+ A norm may also declare **coupling anchors**
448
+ (dec-spor-coupling-norms-declared-first): two flat inline lists, `couples_when:`
449
+ (trigger file globs — "when files matching these change") and `couples_also:`
450
+ (the coupled artifacts that should change in the same edit, or be consciously
451
+ dismissed). A norm carrying both becomes a **coupling norm**: the post-tool
452
+ hook glob-matches every Write/Edit's repo-relative path against the trigger
453
+ sets and, on a hit, injects the targets as an edit-time nudge — once per
454
+ session per norm, deterministic, no LLM (task-spor-coupling-nudge-posttool;
455
+ `SPOR_COUPLING_NUDGE=0` disables). The glob dialect is small: `**` crosses
456
+ path segments, `*` stays within one, `?` is one character, a trailing `/`
457
+ means the whole subtree, and a bare `API.md` anchors at the repo root. An
458
+ entry may be **repo-qualified** as `<slug>:<glob>` (or `repo-<slug>:<glob>`)
459
+ so one norm couples artifacts across repos — a qualified trigger fires only in
460
+ that repo and bypasses the norm's scope (the pin IS the scope), while an
461
+ unqualified trigger applies wherever the norm itself applies: its
462
+ `applies_to_*` selectors when declared, else its `project:` stamp (unstamped =
463
+ every repo — the org-wide case, e.g. `couples_when: [.nvmrc]` /
464
+ `couples_also: [Dockerfile]`). Targets may be qualified the same way for
465
+ display. Either key alone, a scalar value, or the keys on a non-norm type are
466
+ inert (validate warns). The matcher is `lib/kernel/coupling.js`; a graph with
467
+ no coupling norms is byte-identical.
468
+
469
+ The boundary-time consumer of the same anchors is **`spor check`**
470
+ (task-spor-cli-check-coupling-verb): given a change set (uncommitted vs HEAD
471
+ by default; `--staged`, `--range a..b`, or `--files`), it reports each
472
+ coupling norm whose triggers are touched while its same-repo targets are not —
473
+ advisory by default, `--strict` exits 1 for CI/pre-commit; targets pinned to
474
+ another repo surface as reminders, never failures. A coupling norm may
475
+ additionally declare a machine-checkable **value invariant** — two scalar
476
+ keys, `couples_value_a:`/`couples_value_b:`, each `<path>#<regex>` (first
477
+ capture group = the value, e.g. `couples_value_a: .nvmrc#v?(\d+)` /
478
+ `couples_value_b: Dockerfile#FROM node:(\d+)`) — and `spor check` compares the
479
+ two extracted values: "these now disagree" beats "you probably forgot", an
480
+ agreeing invariant suppresses the untouched heuristic, and a disagreement
481
+ reports even when both files were touched. A half-declared or malformed pair
482
+ is inert (validate warns).
483
+
447
484
  Because a norm rides along with no relevance gate and the team trust model lets
448
485
  every writer author one, the briefing renderer treats norm bodies as an
449
486
  **injection surface** (issue-cc-norm-always-on-injection): each is quoted as
@@ -616,6 +653,10 @@ edges:
616
653
  the deterministic router walks `stewards` edges from the question's relevance
617
654
  neighborhood to the closest steward and writes a `routed-to` edge to that
618
655
  person; an unrouted question (no steward matched) surfaces to everyone.
656
+ The question schema's `validate()` door rejects a title/summary/body that is
657
+ ONLY an unfilled template token (`<question>`, `{{text}}`, `[id]`) so a docs
658
+ example run verbatim can't mint an information-free routed ask
659
+ (issue-spor-ask-question-template-placeholder-validation).
619
660
  - **`assigned`** points work (task/issue) at a person; per-person queues filter
620
661
  on it (the queue's `assignee` parameter — `GET /v1/queue?assignee=<person>`,
621
662
  unioned with the person's `stewards` edges; QUEUE.md §5). **`answers`** points
package/README.md CHANGED
@@ -6,6 +6,8 @@ It keeps track of the useful things that normally disappear into chat history: d
6
6
 
7
7
  The name comes from Norwegian: *spor* means the track something leaves behind.
8
8
 
9
+ 📖 **Full documentation: [docs.sporhq.io](https://docs.sporhq.io)** — concepts, getting started, the CLI, the REST API, the MCP surface, and the hosted product.
10
+
9
11
  ## Why use Spor?
10
12
 
11
13
  Coding agents are good at working inside a single session. The problem is everything before and after that session.
@@ -515,6 +517,7 @@ This matters because some hosts cache plugins or hook definitions. Updating the
515
517
 
516
518
  ## More docs
517
519
 
520
+ * [docs.sporhq.io](https://docs.sporhq.io) — the full documentation site: concepts, getting started, CLI, REST API, MCP, and the hosted guide
518
521
  * `GRAPH.md` — graph format, node types, edges, and schema behaviour
519
522
  * `API.md` — REST and MCP server contract
520
523
  * `QUEUE.md` — queue, capture, routing, and workflow details
package/bin/spor-hook.js CHANGED
@@ -162,7 +162,18 @@ async function main() {
162
162
  /* logging must never break fail-open */
163
163
  }
164
164
  }
165
- if (!cfg.enabled()) return;
165
+ if (!cfg.enabled()) {
166
+ // A repo disabled purely by the opt-in DEFAULT that this machine has prior
167
+ // Spor history for gets a one-time discovery hint instead of silence
168
+ // (issue-spor-opt-in-silent-disable-no-indication) — session-start only,
169
+ // evidence-gated, once per repo; see scripts/engines/enable-hint.js.
170
+ if (event === "session-start") {
171
+ const { enableHint } = require("../scripts/engines/enable-hint");
172
+ const hint = enableHint(payload);
173
+ if (hint) emit(hint, payload, host);
174
+ }
175
+ return;
176
+ }
166
177
 
167
178
  // Debounced distill: spool the payload and hand off to a per-session
168
179
  // watcher (one at a time — the lock holds the watcher's pid; stale locks
@@ -204,7 +215,11 @@ async function main() {
204
215
  const engine = ENGINES[event]();
205
216
  const out = await engine(payload);
206
217
  if (!out) return;
218
+ emit(out, payload, host);
219
+ }
207
220
 
221
+ // Write an engine's output envelope to stdout in the host's dialect.
222
+ function emit(out, payload, host) {
208
223
  // Cursor speaks a flat snake_case output: {additional_context} only.
209
224
  // (jq -c framing: compact JSON with a trailing newline.)
210
225
  if (host === "cursor") {
package/bin/spor.js CHANGED
@@ -1701,6 +1701,140 @@ async function fetchRemoteExportNodes(cfg, label) {
1701
1701
  }
1702
1702
  }
1703
1703
 
1704
+ // `spor check` — the coupling-drift report over a diff
1705
+ // (task-spor-cli-check-coupling-verb, dec-spor-coupling-norms-declared-first):
1706
+ // the boundary-time consumer of the same coupling norms the post-tool nudge
1707
+ // serves at edit time (one matcher, lib/kernel/coupling.js). Resolve a change
1708
+ // set via LOCAL git (always — the diff is where you run), load the graph's
1709
+ // coupling norms (local nodes dir, or the team graph via GET /v1/export in
1710
+ // remote mode, the documented graph-wide-sweep path), and report each norm
1711
+ // whose `couples_when` triggers are touched while its `couples_also` targets
1712
+ // are not — plus value-invariant disagreement for norms carrying
1713
+ // couples_value_a/b. Advisory by default (exit 0); --strict exits 1 on
1714
+ // findings for CI/pre-commit enforcement.
1715
+ async function cmdCheck(cfg, args) {
1716
+ const cwd = process.cwd();
1717
+ const topR = git(cwd, ["rev-parse", "--show-toplevel"]);
1718
+ const top = topR.status === 0 ? topR.stdout.trim() : "";
1719
+ if (!top) {
1720
+ err("check: not inside a git repository (the change set is a git diff)");
1721
+ return 1;
1722
+ }
1723
+ const slug = u.projectSlug(top);
1724
+ const range = optVal(args, "range");
1725
+ const staged = args.includes("--staged");
1726
+ const strict = args.includes("--strict");
1727
+ const json = args.includes("--json");
1728
+ let files = null;
1729
+ const fi = args.indexOf("--files");
1730
+ if (fi >= 0) {
1731
+ files = [];
1732
+ for (let i = fi + 1; i < args.length && !String(args[i]).startsWith("--"); i++) files.push(args[i]);
1733
+ if (!files.length) {
1734
+ err("check: --files needs at least one path");
1735
+ return 1;
1736
+ }
1737
+ }
1738
+ if ((range ? 1 : 0) + (staged ? 1 : 0) + (files ? 1 : 0) > 1) {
1739
+ err("check: --range, --staged, and --files are mutually exclusive");
1740
+ return 1;
1741
+ }
1742
+
1743
+ // The change set, as repo-relative forward-slash paths (git's own spelling).
1744
+ let changed = [];
1745
+ let rightRev = null; // where value invariants read from (--range reads the range's right side)
1746
+ if (files) {
1747
+ changed = files.map((f) => {
1748
+ const abs = path.isAbsolute(f) ? f : path.resolve(cwd, f);
1749
+ return path.relative(top, abs).split(path.sep).join("/");
1750
+ });
1751
+ } else if (range) {
1752
+ const r = git(top, ["diff", "--name-only", range]);
1753
+ if (r.status !== 0) {
1754
+ err(`check: could not resolve --range '${range}' (${(r.stderr || "").trim().split("\n")[0]})`);
1755
+ return 1;
1756
+ }
1757
+ changed = r.stdout.split("\n").filter(Boolean);
1758
+ const i = range.lastIndexOf("..");
1759
+ rightRev = i > 0 ? range.slice(i + 2) || "HEAD" : null;
1760
+ } else if (staged) {
1761
+ const r = git(top, ["diff", "--name-only", "--cached"]);
1762
+ if (r.status !== 0) {
1763
+ err("check: git diff --cached failed");
1764
+ return 1;
1765
+ }
1766
+ changed = r.stdout.split("\n").filter(Boolean);
1767
+ } else {
1768
+ // Default: everything uncommitted vs HEAD (staged + unstaged) plus
1769
+ // untracked files — the mid-session / pre-commit superset, so an unstaged
1770
+ // edit can't silently pass. (The task sketch said "staged by default";
1771
+ // staged-only reports NOTHING for the common unstaged-working-tree case —
1772
+ // false confidence — so the default is the honest superset and --staged is
1773
+ // the narrow pre-commit view.) A repo with no commits yet falls back to
1774
+ // the index.
1775
+ const d = git(top, ["diff", "--name-only", "HEAD"]);
1776
+ if (d.status === 0) changed = d.stdout.split("\n").filter(Boolean);
1777
+ else {
1778
+ const c = git(top, ["diff", "--name-only", "--cached"]);
1779
+ if (c.status === 0) changed = c.stdout.split("\n").filter(Boolean);
1780
+ }
1781
+ const un = git(top, ["ls-files", "--others", "--exclude-standard"]);
1782
+ if (un.status === 0) changed.push(...un.stdout.split("\n").filter(Boolean));
1783
+ }
1784
+ changed = [...new Set(changed)];
1785
+
1786
+ // The coupling norms: local nodes dir, or the team graph via /v1/export.
1787
+ const couplingLib = require(path.join(ROOT, "lib", "kernel", "coupling.js"));
1788
+ const scanDir = (nodesDir) => {
1789
+ let names = [];
1790
+ try {
1791
+ names = fs.readdirSync(nodesDir).sort();
1792
+ } catch {}
1793
+ return couplingLib.scanCouplingEntries((f) => fs.readFileSync(path.join(nodesDir, f), "utf8"), names);
1794
+ };
1795
+ let scan;
1796
+ let cleanupFetched = null;
1797
+ if (cfg.mode() === "remote" && !namesLocalGraph(args)) {
1798
+ const fetched = await fetchRemoteExportNodes(cfg, "check");
1799
+ if (fetched.error) return 1; // already reported (offline / HTTP error)
1800
+ cleanupFetched = fetched.cleanup;
1801
+ scan = scanDir(fetched.nodesDir);
1802
+ } else {
1803
+ const nodesDir = optVal(args, "nodes") || cfg.nodesDir();
1804
+ if (!fs.existsSync(nodesDir)) {
1805
+ err(`no graph at ${nodesDir} — run 'spor init' first`);
1806
+ return 1;
1807
+ }
1808
+ scan = scanDir(nodesDir);
1809
+ }
1810
+ try {
1811
+ const checkLib = require(path.join(ROOT, "lib", "check.js"));
1812
+ const readFile = (rel) => {
1813
+ try {
1814
+ if (rightRev) {
1815
+ const r = git(top, ["show", `${rightRev}:${rel}`]);
1816
+ return r.status === 0 ? r.stdout : null;
1817
+ }
1818
+ return fs.readFileSync(path.join(top, rel), "utf8");
1819
+ } catch {
1820
+ return null;
1821
+ }
1822
+ };
1823
+ const { checked, findings, reminders } = checkLib.runCheck({
1824
+ slug,
1825
+ changed,
1826
+ norms: scan.norms,
1827
+ repoTags: scan.repo_tags[slug] ?? [],
1828
+ readFile,
1829
+ });
1830
+ if (json) out(JSON.stringify({ project: slug, changed, checked, findings, reminders, strict }, null, 2));
1831
+ else out(checkLib.renderReport({ slug, changed, checked, findings, reminders }, { strict }));
1832
+ return strict && findings.length ? 1 : 0;
1833
+ } finally {
1834
+ if (cleanupFetched) cleanupFetched();
1835
+ }
1836
+ }
1837
+
1704
1838
  // analytics folds a graph's git history into created-vs-completed metrics
1705
1839
  // (task-spor-work-analytics-consumer). Unlike query/validate (no server twin) it
1706
1840
  // is dual-mode: local mode runs the in-repo consumer (lib/analytics.js) over
@@ -8331,6 +8465,38 @@ const COMMANDS = {
8331
8465
  ],
8332
8466
  run: (cfg, args) => cmdChanges(cfg, args),
8333
8467
  },
8468
+ check: {
8469
+ group: "Graph", parse: "raw", args: "[--staged|--range <a..b>|--files <f...>] [--strict] [--json]",
8470
+ summary: "coupling-drift report over a diff — triggers touched, targets not",
8471
+ help:
8472
+ "Check a change set against the graph's COUPLING NORMS (norm nodes carrying\n" +
8473
+ "couples_when/couples_also file globs — GRAPH.md \"coupling anchors\"): report\n" +
8474
+ "each norm whose trigger set is touched while its target set is not — the\n" +
8475
+ "boundary-time twin of the edit-time post-tool coupling nudge, sharing one\n" +
8476
+ "matcher (lib/kernel/coupling.js). A norm carrying a value invariant\n" +
8477
+ "(couples_value_a/b: <path>#<regex>) has its two extracted values compared:\n" +
8478
+ "\"these now disagree\" beats \"you probably forgot\", and an agreeing invariant\n" +
8479
+ "suppresses the untouched heuristic. Targets pinned to another repo are\n" +
8480
+ "surfaced as reminders, never failures (verify them there).\n" +
8481
+ "\n" +
8482
+ "The change set is always LOCAL git; the norms are mode-aware (local graph, or\n" +
8483
+ "the team graph via GET /v1/export in remote mode).\n" +
8484
+ "\n" +
8485
+ " --staged check the index only (the pre-commit view); default is\n" +
8486
+ " everything uncommitted vs HEAD plus untracked files\n" +
8487
+ " --range <a..b> check a commit range (CI; value invariants read the right side)\n" +
8488
+ " --files <f...> an explicit file list (paths, repo-relative or absolute)\n" +
8489
+ " --strict exit 1 when findings exist (CI / pre-commit enforcement)\n" +
8490
+ " --json machine-readable {project, changed, checked, findings}\n" +
8491
+ " --nodes <dir> read this local graph dir instead of the resolved home",
8492
+ examples: [
8493
+ "spor check",
8494
+ "spor check --staged --strict",
8495
+ "spor check --range origin/main..HEAD --strict",
8496
+ "spor check --files lib/seed/schema-task.md --json",
8497
+ ],
8498
+ run: (cfg, args) => cmdCheck(cfg, args),
8499
+ },
8334
8500
  export: {
8335
8501
  group: "Graph", parse: "strict", args: "[--gzip] [--history|--auth] [--out <file>]",
8336
8502
  summary: "the nodes/ tarball, or the --history bundle / --auth restore backup (GET /v1/export)",
package/lib/check.js ADDED
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ // check.js — the coupling-drift checker behind `spor check`
3
+ // (task-spor-cli-check-coupling-verb, dec-spor-coupling-norms-declared-first):
4
+ // the boundary-time half of the coupling norms the post-tool nudge serves at
5
+ // edit time. Given a change set (repo-relative paths) and the graph's coupling
6
+ // norms, report each norm whose trigger set (`couples_when`) is touched while
7
+ // its target set (`couples_also`) is not — plus, for norms carrying a VALUE
8
+ // invariant (`couples_value_a`/`couples_value_b`), whether the two extracted
9
+ // values actually agree ("these two now disagree" beats "you probably
10
+ // forgot"). Pure over injected inputs — the CLI resolves git, mode, and norm
11
+ // loading; this module never touches the network.
12
+
13
+ const coupling = require("./kernel/coupling.js");
14
+
15
+ const arr = (v) => (Array.isArray(v) ? v : []);
16
+
17
+ // Does any changed path satisfy one couples_also target entry (same-repo view)?
18
+ function targetTouched(glob, changed) {
19
+ const re = coupling.globToRegExp(glob);
20
+ return changed.some((p) => re.test(p));
21
+ }
22
+
23
+ // Check one norm's value invariant against the change. Returns null when the
24
+ // norm declares none, else:
25
+ // { checked: true, agree, a: {file, value}, b: {file, value} }
26
+ // { checked: false, reason: "cross-repo" | "malformed" | "unreadable", ... }
27
+ // `readFile(relPath)` returns file content or null (the CLI wires it to the
28
+ // working tree, or `git show <rev>:<path>` for --range).
29
+ function checkValueInvariant(norm, { slug, readFile }) {
30
+ if (norm.couples_value_a == null && norm.couples_value_b == null) return null;
31
+ const specA = coupling.parseValueSpec(norm.couples_value_a);
32
+ const specB = coupling.parseValueSpec(norm.couples_value_b);
33
+ if (!specA || !specB) return { checked: false, reason: "malformed" };
34
+ for (const s of [specA, specB]) {
35
+ if (s.repo !== null && !coupling.repoMatches(s.repo, slug)) {
36
+ return { checked: false, reason: "cross-repo", file: `${s.repo}:${s.file}` };
37
+ }
38
+ }
39
+ const contentA = readFile(specA.file);
40
+ const contentB = readFile(specB.file);
41
+ if (contentA == null || contentB == null) {
42
+ return { checked: false, reason: "unreadable", file: contentA == null ? specA.file : specB.file };
43
+ }
44
+ const a = coupling.extractValue(contentA, specA.pattern);
45
+ const b = coupling.extractValue(contentB, specB.pattern);
46
+ if (a == null || b == null) {
47
+ return { checked: false, reason: "unreadable", file: a == null ? specA.file : specB.file, pattern_miss: true };
48
+ }
49
+ return { checked: true, agree: a === b, a: { file: specA.file, value: a }, b: { file: specB.file, value: b } };
50
+ }
51
+
52
+ // The check. Inputs:
53
+ // slug — this repo's project slug (scopes the norms, resolves qualifiers)
54
+ // changed — repo-relative forward-slash paths of the change set
55
+ // norms — coupling norms (scanCouplingEntries output)
56
+ // repoTags — this repo's `tags:` (feeds applies_to_tags scope)
57
+ // readFile — (relPath) -> content|null, for value invariants
58
+ // Returns { checked, findings, reminders }: `checked` counts the norms whose
59
+ // trigger set was evaluated against this change; findings are, per norm, one of
60
+ // kind "untouched" — triggers hit, same-repo targets untouched
61
+ // kind "value-disagreement" — the declared invariant's two values differ
62
+ // kind "value-unverifiable" — triggers hit but the invariant could not be
63
+ // read/parsed here (carries the untouched list)
64
+ // A norm whose invariant CHECKS and AGREES emits nothing even when target
65
+ // globs look untouched — the machine-checkable truth beats the heuristic
66
+ // (e.g. the Dockerfile already carried the right version).
67
+ // `reminders` are triggered norms whose OTHER-REPO targets can't be verified
68
+ // by this diff — surfaced as information, never findings (else every
69
+ // cross-repo coupling would fail --strict unconditionally); a norm appears
70
+ // there only when it produced no finding (a finding already carries its
71
+ // cross_repo list).
72
+ function runCheck({ slug, changed, norms, repoTags = [], readFile = () => null }) {
73
+ const findings = [];
74
+ const reminders = [];
75
+ let checkedCount = 0;
76
+ for (const norm of arr(norms)) {
77
+ if (!coupling.isCouplingNorm(norm)) continue;
78
+ checkedCount++;
79
+ const ctx = { slug, repoTags };
80
+ const triggered = arr(changed).filter((p) => coupling.couplingHit(norm, { ...ctx, relPath: p }));
81
+ const invariant = checkValueInvariant(norm, { slug, readFile });
82
+ // The invariant also arms on a change to either of its own files, so a
83
+ // target edited to the WRONG value is caught even when the norm's glob
84
+ // targets were all touched.
85
+ const invariantFiles = [norm.couples_value_a, norm.couples_value_b]
86
+ .map((s) => coupling.parseValueSpec(s))
87
+ .filter((s) => s && (s.repo === null || coupling.repoMatches(s.repo, slug)))
88
+ .map((s) => s.file);
89
+ const invariantArmed = triggered.length > 0 || arr(changed).some((p) => invariantFiles.includes(p));
90
+ if (!triggered.length && !(invariant && invariantArmed)) continue;
91
+
92
+ // Partition targets: same-repo ones are checkable against this diff;
93
+ // other-repo ones can only be surfaced as reminders.
94
+ const untouched = [];
95
+ const crossRepo = [];
96
+ for (const entry of arr(norm.couples_also)) {
97
+ const { repo, glob } = coupling.parseEntry(entry);
98
+ if (repo !== null && !coupling.repoMatches(repo, slug)) {
99
+ crossRepo.push(entry);
100
+ continue;
101
+ }
102
+ if (!targetTouched(glob, arr(changed))) untouched.push(entry);
103
+ }
104
+
105
+ const base = { norm: norm.id, title: norm.title ?? null, triggered, cross_repo: crossRepo };
106
+ const before = findings.length;
107
+ if (invariant && invariantArmed) {
108
+ if (invariant.checked) {
109
+ if (!invariant.agree) {
110
+ findings.push({ kind: "value-disagreement", ...base, untouched, a: invariant.a, b: invariant.b });
111
+ }
112
+ // agree -> the invariant is authoritative; no untouched finding.
113
+ } else if (triggered.length && untouched.length) {
114
+ findings.push({ kind: "value-unverifiable", ...base, untouched, reason: invariant.reason, file: invariant.file ?? null });
115
+ }
116
+ } else if (triggered.length && untouched.length) {
117
+ findings.push({ kind: "untouched", ...base, untouched });
118
+ }
119
+ if (findings.length === before && triggered.length && crossRepo.length) {
120
+ reminders.push({ norm: norm.id, title: norm.title ?? null, triggered, cross_repo: crossRepo });
121
+ }
122
+ }
123
+ return { checked: checkedCount, findings, reminders };
124
+ }
125
+
126
+ // The human report. One block per finding; the summary line always prints so
127
+ // a clean run is visibly a run, not a silent no-op. Cross-repo reminders
128
+ // render after the findings, informational only.
129
+ function renderReport({ slug, changed, checked, findings, reminders = [] }, { strict = false } = {}) {
130
+ const lines = [];
131
+ const n = findings.length;
132
+ lines.push(
133
+ `spor check: ${n === 0 ? "ok" : `${n} finding${n === 1 ? "" : "s"}`} (${changed.length} changed file${changed.length === 1 ? "" : "s"}, ${checked} coupling norm${checked === 1 ? "" : "s"}, project ${slug})`
134
+ );
135
+ for (const f of findings) {
136
+ lines.push("");
137
+ lines.push(`${f.norm}${f.title ? ` — ${f.title}` : ""}`);
138
+ if (f.triggered.length) lines.push(` triggered by: ${f.triggered.join(", ")}`);
139
+ if (f.kind === "value-disagreement") {
140
+ lines.push(` value disagreement: ${f.a.file} (${f.a.value}) != ${f.b.file} (${f.b.value})`);
141
+ }
142
+ if (f.untouched && f.untouched.length && f.kind !== "value-disagreement") {
143
+ lines.push(` untouched: ${f.untouched.join(", ")}`);
144
+ }
145
+ if (f.kind === "value-unverifiable") {
146
+ lines.push(` value invariant not verifiable here (${f.reason}${f.file ? `: ${f.file}` : ""})`);
147
+ }
148
+ if (f.cross_repo && f.cross_repo.length) {
149
+ lines.push(` other-repo targets (verify there): ${f.cross_repo.join(", ")}`);
150
+ }
151
+ }
152
+ for (const r of reminders) {
153
+ lines.push("");
154
+ lines.push(`${r.norm}${r.title ? ` — ${r.title}` : ""} (reminder)`);
155
+ if (r.triggered.length) lines.push(` triggered by: ${r.triggered.join(", ")}`);
156
+ lines.push(` other-repo targets (verify there): ${r.cross_repo.join(", ")}`);
157
+ }
158
+ if (n > 0 && !strict) lines.push("", "(advisory — exit 0; run with --strict to fail on findings)");
159
+ return lines.join("\n");
160
+ }
161
+
162
+ module.exports = { runCheck, renderReport, checkValueInvariant };
package/lib/config.js CHANGED
@@ -72,7 +72,7 @@ const REPO_FORBIDDEN_KEYS = ["token"];
72
72
  // warning so a silently-ignored setting is visible rather than mysterious.
73
73
  const KNOWN_KEYS = new Set([
74
74
  "mode", "server", "token", "home", "nodes", "enabled", "org",
75
- "search", "queue", "distill", "nudge", "claimNudge", "inferCommits", "dispatch",
75
+ "search", "queue", "distill", "nudge", "claimNudge", "couplingNudge", "inferCommits", "dispatch",
76
76
  // Path-scoped sub-briefs for monorepos (dec-spor-monorepo-path-scoped-briefs):
77
77
  // an optional relative-subtree-path -> brief-id map. Committable repo config,
78
78
  // kept SEPARATE from the flat `.spor` identity marker; session-start routes to
@@ -101,6 +101,8 @@ const ENV_MAP = [
101
101
  ["NUDGE_TIMEOUT", "nudge.timeoutMs"], // bound a hung nudge backend (ms)
102
102
  ["CLAIM_NUDGE", "claimNudge.enabled"], // SPOR_CLAIM_NUDGE=0 disables the claim heartbeat+nudge
103
103
  ["CLAIM_NUDGE_TIMEOUT", "claimNudge.timeoutMs"], // bound the lease-lookup/heartbeat curls (ms)
104
+ ["COUPLING_NUDGE", "couplingNudge.enabled"], // SPOR_COUPLING_NUDGE=0 disables the edit-time coupling nudge
105
+ ["COUPLING_NUDGE_TIMEOUT", "couplingNudge.timeoutMs"], // bound the remote coupling-snapshot download (ms)
104
106
  ["INFER_COMMITS", "inferCommits.enabled"],
105
107
  ["INFER_THRESHOLD", "inferCommits.threshold"],
106
108
  ["QUEUE_FRONT", "queue.front.enabled"], // SPOR_QUEUE_FRONT=0 disables local git-derived front
@@ -661,6 +663,19 @@ class Config {
661
663
  if (explicit !== undefined) return this.getBool("enabled", true);
662
664
  return this._repoMarker;
663
665
  }
666
+ // True iff the plugin is inactive purely by the opt-in DEFAULT — no explicit
667
+ // `enabled` flag anywhere in the cascade, no mode:off, no repo marker
668
+ // (issue-spor-opt-in-silent-disable-no-indication). This is the one inactive
669
+ // state where a discovery hint is appropriate: an explicit opt-out
670
+ // (enabled:false, SPOR_ENABLED=0, mode:off) is a deliberate choice and must
671
+ // stay silent.
672
+ disabledByDefault() {
673
+ return (
674
+ this.get("mode", "auto") !== "off" &&
675
+ this.get("enabled", undefined) === undefined &&
676
+ !this._repoMarker
677
+ );
678
+ }
664
679
  }
665
680
 
666
681
  module.exports = { loadConfig, Config, DEFAULTS, DEFAULT_SERVER, ENV_MAP, repoMarkerGraph, repoMarkerOrg, repoMarkerPresent };