@sporhq/spor 0.11.0 → 0.12.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.11.0",
5
+ "version": "0.12.1",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/API.md CHANGED
@@ -90,6 +90,10 @@ Output: `{ "found": bool, "text": "<digest or full neighborhood>",
90
90
 
91
91
  Input `{ "id": "dec-..." }` → full raw markdown, parsed frontmatter, and
92
92
  `revision` (git blob SHA) for use in updates. Unknown id → not-found error.
93
+ **Read-time enrichment** rides along on the same response: the node's active
94
+ schema may carry a `get(node, ctx)` hook (GRAPH.md) that attaches derived
95
+ context as extra top-level keys — e.g. `resolution` (what answered/resolved
96
+ this node). These are additive; a client ignores keys it does not know.
93
97
 
94
98
  ### `put_node`
95
99
 
@@ -314,10 +318,11 @@ endpoint is the REST twin of a core call:
314
318
  | Endpoint | Typical caller | Semantics |
315
319
  |---|---|---|
316
320
  | `GET /v1/status` | session-start, monitoring | `{node_count, projects: {...}, head, uptime, metrics}`; doubles as the health check. `?titles=1` adds `titles: [{id, type, project, title}]` — the one-round-trip graph index the distiller dedups against |
317
- | `GET /v1/me` | `spor whoami`/`status`, onboarding | identity echo for the bearer token → `{person, name, email, bound, is_admin}`. `bound:false` means the token authenticates but maps to **no person node** (legacy/OAuth, or minted before the node existed), so routed questions and the personal queue will be empty — the client warns on it (the silent identity-degradation signal). `is_admin` reflects the `stewards→root` edge that gates the token-admin surface |
321
+ | `GET /v1/me` | `spor whoami`/`status`, onboarding | identity echo for the bearer token → `{person, name, email, bound, is_admin, org}`. `bound:false` means the token authenticates but maps to **no person node** (legacy/OAuth, or minted before the node existed), so routed questions and the personal queue will be empty — the client warns on it (the silent identity-degradation signal). `is_admin` reflects the `stewards→root` edge that gates the token-admin surface. `org` is the slug this tenant routes to (`SPOR_ORG`/legacy `SUBSTRATE_ORG`, else `"local"`); it lets a client key its `(issuer, org)` credential store for an **opaque** `spor_oat_`/`spor_pat_` token that carries no readable `org` claim — the client falls back to it after `--org` and the JWT `org` claim (task-spor-frontdoor-me-org-echo). A connector JWT's `org` claim is enforced equal to this echo |
322
+ | `GET /v1/me/org-choices` | `spor auth list` (live membership refresh) | re-queries the IdP's *current* org membership for the held credential's subject and returns `{org_choices: [{slug, label, default?}], source: "idp"\|"bound"}` — `source:"idp"` is a true live enumeration (orgs added/removed since the last login surface without re-authenticating); `source:"bound"` means a single org-scoped token the server couldn't expand (no enumeration). The client treats only `source:"idp"` as live and **fails open** to its cached tenant listing on anything else — `source:"bound"`, a `502 {error.code:"membership_requery_failed"}` (IdP unreachable), a `404` (older server without the endpoint), or any transport/parse error (task-spor-cli-auth-list-live-membership-requery; server half task-spor-frontdoor-held-credential-membership-requery) |
318
323
  | `GET /v1/briefing/{project}` | session-start | read the `brief-<project>` node → `{found, version, body, project_brief?, graph_status}`. The slug resolves through project-node aliases (GRAPH.md "Project identity nodes") before lookup. A BARE repo slug also rides up to its home-project grouping: the grouping's `brief-<grouping>` node returns alongside as `project_brief` (the product context spanning sibling repos), matching the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping); passing the repo NODE id (`repo-<slug>`) is the escape hatch that returns only the repo brief, no `project_brief`. Optional `?fp=root:<sha>,remote:<host/path>,...` carries the repo's fingerprints: the server learns them onto the owning project node, and an unknown slug with a known fingerprint files an alias proposal in the queue |
319
324
  | `POST /v1/digest` `{query, root?, project?, min_sim?}` | prompt-context, /spor:brief | digest-mode compile → `{found, text}`; `found: false` is a successful empty result. `root` is the structural-walk twin of `query` (the two are mutually exclusive; `root` wins, an unknown id is `422`). Optional `project` is the session slug: the server scopes the compile to it — the same-project relevance boost, the grouping union, and the `always_on` norm `applies_to_*` ride-along — resolving the slug through project-node aliases/groupings inside compile (dec-spor-queue-slug-resolves-to-grouping), exactly as `/v1/queue` does. A bad slug is `422`; **omitting `project` runs the digest project-blind (byte-identical to before)**, so older clients that send only `{query}` are unaffected |
320
- | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; a live inbound resolves/answers edge rides along as `resolution` (carrying the resolver's `summary`/`title` and a `lagging` flag set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer), and open gardener findings about the node ride along as `open_findings` |
325
+ | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; the node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open gardener findings about the node ride along as `open_findings`, and a node marked stale by an inbound supersedes edge as `superseded_by`. All enrichment is additive top-level keys; ignore unknown ones |
321
326
  | `POST /v1/nodes` | drain-outbox, mechanical writers | `put_node` semantics, batch: `{nodes: [...], if_exists: "skip"}` (entries may be raw strings or `{node, if_exists, revision}`) → `{results: [...]}`, 207 when any entry failed |
322
327
  | `POST /v1/nodes/{id}/edges` `{type, to, attrs?}` | scripts, mechanical writers | `add_edge` semantics (§1): normalize/flip, dedupe, append — no revision echo. Optional `attrs` adds trailing flat edge attributes (e.g. a per-assignment `profile:` override); re-adding the same edge with different attrs upserts the set |
323
328
  | `POST /v1/nodes/{id}/status` `{status}` | scripts, mechanical writers | `set_status` semantics (§1): one-scalar update through the `transitions()` gate. Setting a work node to an in-progress status also CLAIMS it (same lease as `/claim` below) |
@@ -428,6 +433,33 @@ anything with a token.
428
433
  `{name, email}` attribution record. Access tokens are `spor_oat_…` (30d;
429
434
  legacy `sub_oat_…` accepted); refresh tokens are `spor_ort_…` (90d,
430
435
  rotating, single-use). Authorization codes are single-use, 10-minute.
436
+ - **CLI interactive sign-in — the device authorization grant.** `spor auth
437
+ login` (flat alias `spor login`) defaults to the OAuth 2.0 device
438
+ authorization grant (RFC 8628), brokered at the Spor front door so it works
439
+ headless / over SSH (dec-spor-cli-auth-device-grant-front-door). Flow:
440
+ `POST /oauth/device_authorization {client_id?, scope?}` →
441
+ `{device_code, user_code, verification_uri, verification_uri_complete,
442
+ expires_in, interval}`; the CLI prints the URL + code (auto-opening a local
443
+ browser when one is present), the human approves in any browser (the
444
+ verification leg runs the same AuthKit login + org resolution as the
445
+ connector flow), and the CLI polls `POST /oauth/token
446
+ {grant_type: urn:ietf:params:oauth:grant-type:device_code, device_code}` —
447
+ answering `authorization_pending`/`slow_down` until approval, then minting the
448
+ same person-bound, **org-scoped**, refreshable `spor_oat_…`/`spor_ort_…` pair
449
+ `addGrant` mints. `--web` is the localhost-loopback optimization for when a
450
+ browser is on the same machine (OAuth 2.1 authorization-code + PKCE, RFC 8252):
451
+ the CLI binds a one-shot `http://127.0.0.1:<port>/callback` listener,
452
+ anonymously registers a public client for it (`POST /oauth/register`, RFC 7591),
453
+ opens the browser to `GET /oauth/authorize {response_type=code, client_id,
454
+ redirect_uri, code_challenge, code_challenge_method=S256, state, scope?}`,
455
+ captures the redirected `?code` (CSRF-checked against `state`), and exchanges it
456
+ at `POST /oauth/token {grant_type: authorization_code, code, code_verifier,
457
+ client_id, redirect_uri}` for the same token pair; it then best-effort
458
+ unregisters the throwaway client (RFC 7592 `DELETE`). It falls back to the
459
+ device grant when the front door exposes no loopback/DCR endpoints. `spor auth
460
+ login <url> <token>` / `spor join <url>
461
+ <token>` is the non-interactive paste path; CI stays `SPOR_TOKEN`. The minted
462
+ credential is stored per-tenant (§6.2).
431
463
  - **Render tickets (shared lens links).** `POST /v1/lens/{id}/ticket` (§3)
432
464
  mints a signed, expiring, **read-only** ticket carrying `{lens_id,
433
465
  sharer_person_id, exp}` — the credential a *shared* view link carries instead
@@ -476,10 +508,15 @@ spellings are still read — dual-read back-compat window); unset means local
476
508
  mode, reading `$SPOR_HOME` directly:
477
509
 
478
510
  ```
479
- SPOR_SERVER=https://spor.example.com
511
+ SPOR_SERVER=https://api.sporhq.io # hosted Spor REST base (the onboarding default)
480
512
  SPOR_TOKEN=spor_pat_... # per-user token (§4)
513
+ SPOR_ORG=acme # select a stored tenant by org (§6.2)
481
514
  ```
482
515
 
516
+ `spor join <token>` writes both for you, defaulting `SPOR_SERVER` to the hosted
517
+ base `https://api.sporhq.io`; pass a URL (`spor join <url> <token>`) to point at
518
+ a self-hosted server instead.
519
+
483
520
  **Opt-in activation.** Spor is opt-in per repo: with the plugin installed, the
484
521
  hooks are a full no-op (no context injected, nothing distilled) in any repo that
485
522
  has not opted in. A repo is active when its mode is not `off` AND either an
@@ -548,3 +585,41 @@ Contract:
548
585
  `nodes/` and brief `history/` are committed. The SessionEnd distiller leaves
549
586
  distilled nodes **uncommitted** (for the human PR flow) instead of
550
587
  auto-committing when the graph home is the same git repo as the code repo.
588
+
589
+ ### 6.2 Multi-tenant credentials (the credential store + tenant selector)
590
+
591
+ Server tokens are **org-scoped** (the `org` claim is the routing + isolation
592
+ key), so a person in N orgs holds N credentials. The client is multi-tenant
593
+ (dec-spor-client-cli-mode-tenant-resolution): tokens live in a credential store
594
+ **keyed by `(issuer, org)`** at `$SPOR_HOME/auth/credentials.json` (mode `0600`,
595
+ machine-local — never committed, always in the shared-graph `.gitignore`):
596
+
597
+ ```
598
+ { "version": 1,
599
+ "tenants": {
600
+ "<server>/<org>": { "server": "...", "org": "...", "person": "...",
601
+ "email": "...", "access_token": "...",
602
+ "refresh_token": "...", "exp": 1234567890 } },
603
+ "default": "<server>/<org>" }
604
+ ```
605
+
606
+ - **Acquire.** `spor auth login` (device grant, §4) and `spor join <url>
607
+ <token>` (paste) both **ADD** a tenant — they never overwrite a sibling. The
608
+ org is read from the token (JWT `org` claim) or `--org`. The first tenant
609
+ becomes the active default.
610
+ - **Manage.** `spor auth list` (tenants + active + token health, with a **live
611
+ org-membership refresh** — one `GET /v1/me/org-choices` against the active
612
+ tenant's issuer surfaces orgs added/removed since the last login, fail-open to
613
+ the cached listing when the server can't enumerate; task-spor-cli-auth-list-
614
+ live-membership-requery), `spor auth switch <org>`, `spor auth whoami
615
+ [--all]`, `spor auth logout [<org>|--all]`.
616
+ - **Tenant selector** (which credential is active), highest wins:
617
+ `--org`/`--server` flag > `SPOR_SERVER`(+`SPOR_TOKEN`)/`SPOR_ORG` env > repo
618
+ `.spor` `org:` marker (committable, nearest-ancestor — the remote-mode sibling
619
+ of the `graph:` binding in §6.1) > store `default` > legacy flat config.json
620
+ `server`+`token` (migrated on read) > local.
621
+ - **Refresh.** A 401/403 on a tenant carrying a `refresh_token` transparently
622
+ refreshes against its issuer (`grant_type=refresh_token`) and retries once.
623
+ - **Byte-identical.** With no credential store and only a flat
624
+ `server`+`token` or `SPOR_*` env set, every resolved value equals the prior
625
+ single-tenant behavior (norm-cc-byte-identical-refactor).
package/GRAPH.md CHANGED
@@ -49,6 +49,15 @@ Rules:
49
49
  - `summary` is mandatory and must stand alone — most consumers only ever see
50
50
  the summary.
51
51
  - `date` is YYYY-MM-DD (date of the underlying event, not of node creation).
52
+ System `created_at`/`updated_at` are NOT stored in node bytes — they are
53
+ derived from the graph repo's git history into an in-memory `graph.timestamps`
54
+ index at `loadGraph` (the FIRST commit touching `nodes/<id>.md` is `created_at`,
55
+ the LAST is `updated_at`), so node files stay byte-identical and git stays the
56
+ single source of truth (dec-spor-git-derived-timestamps). The index is lazy
57
+ (off the no-LLM prompt path), HEAD-keyed and cached under `cache/`. An optional
58
+ explicit frontmatter `created_at`/`updated_at` OVERRIDES the git-derived value
59
+ (the escape hatch for squash/rebase graphs); `date` is the last-resort fallback
60
+ when git has nothing — it stays distinct from the system `created_at`.
52
61
  - `author` and `authored_via` are optional. The remote server (see API.md
53
62
  §1) stamps `author: Name <email>` and `authored_via:
54
63
  mcp|rest|capture|gardener` from the authenticated identity on every node
@@ -238,12 +247,23 @@ return values cross back, so `require`, `process`, `eval`, the `Function`
238
247
  constructor, host prototypes, ambient time/IO, and unbounded loops are all
239
248
  unavailable (a runaway hits a fuel/memory interrupt). A hook that throws does not
240
249
  wave the write through — it **rejects** it. Write the functions accordingly: no
241
- external state, no side effects, decide only from the arguments. Two more
250
+ external state, no side effects, decide only from the arguments. Three more
242
251
  attached exports are recognized: `queueSignals(node, ctx)` (a `{ name: number }`
243
- map blended into the decision-queue ranking) and named upgrade functions
244
- referenced from `payload.upgrades` (lazy, forward-only field migrations on a
245
- `schema_version` bump). The **client** half (hooks, the `spor` CLI, `lib/`) only
246
- *parses and indexes* this code for the registry knobsit never executes it;
252
+ map blended into the decision-queue ranking); `get(node, ctx)` the **read-time
253
+ enrichment** hook, run on `get_node`, returning an object whose keys ride along on
254
+ the read. Where `transitions()` is the write-time gate, `get()` is its read-time
255
+ peer: the server hands it a *bounded one-hop neighborhood* (`ctx.neighbors` this
256
+ node's inbound + outbound edges each with the neighbor's `{id, edge, dir, type,
257
+ status, title, summary, date, superseded}`, capped — plus `ctx.non_resolving_statuses`
258
+ and `ctx.terminal`), and the hook attaches derived context — e.g. an answered
259
+ `question` surfacing WHAT answered it. It is read-only and **fail-soft** (a throw
260
+ drops the enrichment, never breaks the read; the only hook that fails open rather
261
+ than closed); reserved core keys (`id`/`raw`/`frontmatter`/`revision`) can't be
262
+ clobbered. The seed `question`/`issue`/`task`/`incident` schemas carry one, the
263
+ single mechanism that expresses the resolution ride-along. Finally, named upgrade
264
+ functions referenced from `payload.upgrades` (lazy, forward-only field migrations
265
+ on a `schema_version` bump). The **client** half (hooks, the `spor` CLI, `lib/`)
266
+ only *parses and indexes* this code for the registry knobs — it never executes it;
247
267
  the server is the sole executor.
248
268
 
249
269
  Resolution and rollout: a graph-resident schema always beats the seed pack, and
package/QUEUE.md CHANGED
@@ -118,6 +118,15 @@ export function transitions(node, proposed, graph) { ... }
118
118
  export function queueSignals(node, graph, activity) {
119
119
  return { blocking: ..., heat: ..., staleness: ..., age_days: ... };
120
120
  }
121
+
122
+ // get(node, ctx) — read-time enrichment, run on get_node; the read-time peer of
123
+ // transitions(). The host hands in a bounded one-hop neighborhood (ctx.neighbors,
124
+ // not a live graph), and the returned object's keys ride along on the read.
125
+ // Read-only, fail-soft. Expresses the resolution ride-along on the seed
126
+ // question/issue/task/incident schemas (task-spor-schema-get-hook-readtime-enrichment).
127
+ export function get(node, ctx) {
128
+ return { resolution: ... };
129
+ }
121
130
  ```
122
131
  ```
123
132
 
@@ -402,8 +411,30 @@ signals via its schema's `queueSignals()`:
402
411
  so provenance hubs can't ride it; capped below the p1 bump so human
403
412
  priority stays supreme; and the why-line states the actual window
404
413
  ("N writes in the last D days") (dec-cc-queue-front-from-attribution).
414
+ The continuity loop ("work X → X stays on top") terminates by finishing X
415
+ (a resolves edge retires it) — EXCEPT a task **held open on an external
416
+ gate**, which has nothing to resolve, so each held pass re-raises front and
417
+ re-surfaces it. The **held-task self-limit** breaks that loop
418
+ (task-spor-queue-front-loop-self-limit-on-held-tasks): an OPEN task carrying
419
+ an inbound non-resolving outcome (an artifact/decision linked by any edge but
420
+ resolves/answers) with no live resolving edge and no live blocker has its
421
+ front damped to 0 in the score and its suggestion flipped `do → triage`
422
+ ("resolve, gate with blocked-by, set wake, or abandon") — the same posture
423
+ staleness takes (flip the suggestion, don't boost the score). `signals.front`
424
+ keeps the raw count so a `queue-policy` rank() can re-weight. The read-time
425
+ twin is the `schema-task` `get()` hook, which rides a `held` note along on
426
+ `get_node`.
405
427
  - **staleness** — anchors superseded or gone; high staleness suggests
406
428
  closing, not doing.
429
+ - **cold_neighbors** — the count of the node's traversable neighbors whose
430
+ git-derived `updated_at` is newer than its own: a node that went cold while its
431
+ neighborhood kept moving ("context moved around it"). Fed by the
432
+ `graph.timestamps` index the caller injects as `timestamps`
433
+ (task-spor-git-derived-timestamp-index); like `needed_by_days` it rides along
434
+ ONLY when the index is present and the count is >0, so the queue is
435
+ byte-identical without it (conformance, the prompt path, a non-git home). It is
436
+ surfaced (signal + why-line), not yet a score weight — a ranking contribution is
437
+ a deliberate tuning change deferred to its own task.
407
438
  - **age**, and any org-specific signal the schema's code adds (SLA clocks,
408
439
  sprint membership, whatever the org's process actually is).
409
440
 
@@ -606,7 +637,10 @@ the structural truth:
606
637
  §2.4 sandbox in step 4 alongside `transitions()`. The blend shipped
607
638
  opinionated (§8): `priority bump + 3·blocking − 3·blocked_by +
608
639
  min(log₂(1+front), 5) + log₂(1+heat) + age/30 (capped) + neededBy urgency (0-5)`, with staleness ≥ 0.5 flipping the item's suggestion to
609
- "close". The `needed_by: YYYY-MM-DD` deadline term is the inverse of
640
+ "close" and the held-task self-limit flipping it to "triage" (front damped
641
+ to 0) for an open task with a recorded non-resolving outcome but no resolver
642
+ and no blocker (task-spor-queue-front-loop-self-limit-on-held-tasks). The
643
+ `needed_by: YYYY-MM-DD` deadline term is the inverse of
610
644
  `wake`: where `wake` hides a node until its date, `needed_by` keeps it
611
645
  visible from creation and ramps its score linearly over a 30-day window to
612
646
  3 at the date, then on to a hard cap of 5 once overdue — surfacing
package/README.md CHANGED
@@ -198,11 +198,21 @@ answered from what's already there, it routes to the person most likely to
198
198
  know, and their answer flows back into the graph as a node — so the next
199
199
  person who asks gets it from the graph instead.
200
200
 
201
- A client joins a team graph by pointing at it with two environment variables:
201
+ A client joins a team graph with a token from your team's invite:
202
202
 
203
203
  ```bash
204
- export SPOR_SERVER=https://spor.example.com
205
- export SPOR_TOKEN=...
204
+ spor join spor_pat_... # hosted Spor (https://api.sporhq.io)
205
+ ```
206
+
207
+ `spor join` writes the server URL and token to your user config and confirms
208
+ the connection. The URL defaults to the hosted Spor service
209
+ (`https://api.sporhq.io`); pass your own to point elsewhere
210
+ (`spor join https://spor.example.com spor_pat_...`). The equivalent two
211
+ environment variables work too:
212
+
213
+ ```bash
214
+ export SPOR_SERVER=https://api.sporhq.io
215
+ export SPOR_TOKEN=spor_pat_...
206
216
  ```
207
217
 
208
218
  Set those and the client talks to the team graph over REST and MCP; leave