@sporhq/spor 0.10.0 → 0.12.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,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.10.0",
5
+ "version": "0.12.0",
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
 
@@ -236,11 +240,18 @@ Omitting both (or passing empty arrays) filters nothing.
236
240
  ### `ask_question`
237
241
 
238
242
  File a question the graph could not answer. Input `{ "text": "<the
239
- question>", "title"?: "<short title>", "mentions"?: ["<node id>", ...] }`
240
- (routing considers `mentions` first). The question becomes a durable node,
241
- deterministically routed to the steward of the closest relevant node
242
- (unrouted if none matches), and joins the decision queue until answered.
243
- Answer by writing a node with an `answers` edge to the question.
243
+ question>", "title"?: "<short title>", "mentions"?: ["<node id>", ...],
244
+ "project"?: "<slug>" }` (routing considers `mentions` first). The question
245
+ becomes a durable node, deterministically routed to the steward of the closest
246
+ relevant node (unrouted if none matches), and joins the decision queue until
247
+ answered. Answer by writing a node with an `answers` edge to the question.
248
+
249
+ By default the question's project is derived from its relevance neighborhood
250
+ (its `mentions`, then the compiler's picks), falling back to the asker's home
251
+ project. An explicit `project` overrides that derivation — pass it for a
252
+ mention-less question whose neighborhood would otherwise yield nothing (a
253
+ dispatched background agent injects its session project here, since the
254
+ launcher environment never reaches it).
244
255
 
245
256
  ### `run_workflow`
246
257
 
@@ -307,10 +318,11 @@ endpoint is the REST twin of a core call:
307
318
  | Endpoint | Typical caller | Semantics |
308
319
  |---|---|---|
309
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 |
310
- | `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) |
311
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 |
312
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 |
313
- | `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 |
314
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 |
315
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 |
316
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) |
@@ -324,7 +336,7 @@ endpoint is the REST twin of a core call:
324
336
  | `POST /v1/distill/report` | distill | sweep telemetry, journal-only (no store mutation): `{facts, captured?, spooled?, rejected?, project?, session?}` → `{status: "reported"}`; zero-fact sweeps report too |
325
337
  | `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
326
338
  | `GET /v1/queue?project=&assignee=&type=&exclude_type=&limit=&offset=` | /spor:next, session-start | the ranked decision queue: `{items, count, offset, returned_count, total_count, truncated, next_offset, counts_by_type, counts_by_project, counts_by_suggest, muted?, dormant?, questions, asked, findings, pending, reviews, policy?, generated_at}` — items retired by a live resolves/answers edge are excluded; items hidden by the viewer's `queue_mute` or parked by a future `wake:` date (QUEUE.md §4) are counted, never silently dropped; `questions`/`findings`/`pending` are the routed-to-me-plus-unrouted views for the authenticated identity, `asked` is the questions you filed, and `reviews` is the nodes whose review is requested of you (an open `review-requested` edge to your person node — explicitly targeted, no unrouted fallback). `limit` is the page size (default 20, **max 100**, clamped not rejected) and `offset` skips that many items in the ranked order (default 0); the `counts_*`/`total_count` aggregates always cover the FULL ranked set regardless of the page, so one call answers "how many issues vs tasks" without paging, while `truncated`/`next_offset` let a client walk the rest by re-requesting with `offset=next_offset` until `next_offset` is null. Pagination is offset over a point-in-time ranked slice (the queue re-ranks every call), not a cursor — it resumes the same slice only across an unchanged ranking. `project` resolves through the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping): a bare repo slug unions its home-project grouping's member queues, the repo NODE id (`repo-<slug>`) pins one repo, a grouping id (`proj-<slug>`) is used directly; **omitting `project` is the cross-project firehose** (every repo's queue at once). `assignee=<person-id>` scopes to the work that person carries (their `assigned`/`stewards` edges) — a manager's "who is carrying what"; `assignee=me` binds to the caller (empty if the token maps to no person node). `type=`/`exclude_type=` (comma-separated, repeatable) whitelist/blacklist node types from the ranking (exclude wins on overlap) — a hard scope filter applied before scoring, so the aggregates describe the filtered queue (task-cc-queue-filtering-enhancements) |
327
- | `POST /v1/questions` `{text, title?, mentions?}` | ask_question's REST twin | file a question node; deterministically routed to the steward of the closest relevance-neighborhood node, unrouted if none → 201 `{status, id, routed_to, via, asker, revision, warnings}` |
339
+ | `POST /v1/questions` `{text, title?, mentions?, project?}` | ask_question's REST twin | file a question node; deterministically routed to the steward of the closest relevance-neighborhood node, unrouted if none → 201 `{status, id, project, routed_to, via, asker, revision, warnings}`. `project` is derived from the relevance neighborhood (then the asker's home project) unless an explicit `project` slug overrides it — pass that for a mention-less question (a dispatched agent injects its session project); a malformed slug → 400 |
328
340
  | `POST /v1/gardener` | ops cron / on demand | run a gardener sweep now; findings filed as queue items → `{filed, resolved, ..., generated_at}` |
329
341
  | `GET /v1/lens/{id}/render?format=html\|text\|json` | browsers, teammates without a checkout | run a lens OR workspace node and render its view tree (html default, plain text, or the raw tree as json). Read-only — no action forms; writes stay with `/v1/nodes` and the MCP tools. Auth is the caller's bearer header OR a signed read-only **render ticket** for shared links (browser links can't carry an Authorization header): `?ticket=<blob>` is accepted once and exchanged via a 302 for an HttpOnly `spor_render_ticket` cookie (kept out of URLs, logs, and view-to-view hrefs). The ticket binds `$viewer` to the recorded sharer and the render shows a "Viewing as &lt;sharer&gt;" banner. The former `?token=<PAT>` sharing path is **removed** — a shared link can never carry a write-capable credential |
330
342
  | `POST /v1/lens/{id}/ticket` `{expires?}` | sharing a view | mint a signed, expiring, read-only render ticket for the lens/workspace, recording the authenticated caller as the sharer → `{ticket, url, lens_id, sharer_person_id, exp}`. `expires` is `<N>d` or an ISO date (default `7d`, max `30d`); the caller must be bound to a person node (else `422 no_person`). The ticket carries no write scope and is honored only on the render route |
@@ -336,6 +348,10 @@ endpoint is the REST twin of a core call:
336
348
  | `POST /v1/admin/agents` `{label, owner?, id?, pubkey?}` | `spor agent create`, onboarding | create a person-owned `agent` node + its `owned-by` edge (`owner` defaults to the caller's person; `id` derives from `label`) → 201 `{id, owner, spiffe, pubkey, status, revision}`. 409 dup id / 422 invalid / 403 non-admin. Admin-only, same `stewards→root` gate as `/v1/admin/tokens` |
337
349
  | `POST /v1/agents/{id}/token` `{session?, audience?, expires?}` | `spor dispatch` | **self-serve** (NOT admin): mint a short-TTL, per-session token scoped to agent `{id}` → 201 `{token, expires_at, agent, session}` (`session: null` when deferred). Authorized iff the caller's person **owns** the agent (its `owned-by` edge) — else `403`; `404` unknown agent; `422` a SUPPLIED `session` that is malformed. `session` is now **OPTIONAL** (deferred binding, dec-spor-dispatch-bg-session-late-bind): `spor dispatch` mints it deferred because `claude --bg` self-allocates the run session only at launch, then binds the real one via `POST /v1/agents/session` below. The token carries `{agent, session?}` (the person is derived from the `owned-by` edge at verify time); a write under it is stamped agent-on-behalf-of-person (§1). A caller `expires` may only shorten the default TTL, never extend it |
338
350
  | `POST /v1/agents/session` `{session}` | `spor dispatch` (post-launch) | **late session binding** for a session-deferred agent token (dec-spor-dispatch-bg-session-late-bind). Authenticated by the **agent token itself** (the bearer hash identifies its own record — no agent id in the path, no ownership re-check), so only an agent-scoped token may call it (`403` otherwise). Sets that token's `session` → `200 {ok, agent, session}`. **Write-once**: idempotent on the same value (`{unchanged: true}`), `409 conflict` on a different one (a token's session is provenance, not a mutable field); `422` missing/malformed `session`. Every subsequent write under the token then stamps the bound session |
351
+ | `POST /v1/agents/{id}/capabilities` `{harnesses?, reachable_mcp?, skills?, plugins?, deny?}` | `spor capabilities publish`, session-start auto-publish | **publish** this box's machine capabilities to the fleet scheduler (task-spor-remote-fleet-scheduler, dec-spor-machine-profile-satisfiability). The remote twin of the machine-local `dispatch.capabilities` map: the server collapses the body with the SAME `effectiveCapabilities()` the client runs (so a raw `{probed,declared,deny}` map or the already-flat axes both work; also accepts a `{capabilities: {...}}` envelope) and stores it BESIDE the agent node (operational store, not the durable git-tracked node — capabilities are machine-local, probe-refreshed, never committed) → `200 {agent, capabilities, published_at, last_seen, published_by, session?, changed}`. Authorized iff the caller **owns** the agent (its `owned-by` edge) OR is the agent itself (a self-publish under an agent token) — else `403`; `404` unknown agent; `422` a malformed map. A publish stamps both `published_at` (when the CAPS last changed) and `last_seen` (last contact); `last_seen` ALSO advances on the cheap `POST .../heartbeat` below, and the host-match keys staleness off `last_seen` not `published_at`. Beyond the manual verb, `session-start` AUTO-publishes here in remote mode whenever a `dispatch.agent` is configured (task-spor-fleet-capabilities-autopublish-session-start) — bounded + fail-open, so every session refreshes this box's caps and last-contact without a manual call; disable with `SPOR_CAPABILITIES_PUBLISH=0` |
352
+ | `GET /v1/agents/{id}/capabilities` | steward fleet view, debugging | read back an agent's published capabilities → `200 {agent, capabilities, published_at, last_seen, published_by, session?}`; `404` if none published. Readable by the **owner**, the **agent itself**, or an **admin** (a stewards→root fleet-capacity view) — else `403` |
353
+ | `POST /v1/agents/{id}/heartbeat` | post-tool mid-session liveness tick | **liveness ping** (task-spor-fleet-scheduler-hardening): refresh this box's `last_seen` WITHOUT re-uploading capabilities — the cheap "still here" signal, decoupled from a caps re-publish, so a box that published once and runs for hours stays a live fleet host. The host-match keys staleness off `last_seen`, so a box that keeps heartbeating is never demoted while a genuinely dead one ages out under `max_age` → `200 {agent, capabilities, published_at, last_seen, …}` (the refreshed record). Same owner/self gate as publish — else `403`; `404` unknown agent OR nothing published yet (publish before heartbeat — liveness without caps is meaningless to the scheduler); `422` a malformed agent id. The CLIENT caller (task-spor-fleet-scheduler-client-heartbeat-tick): the `post-tool` hook ticks this in REMOTE mode whenever a `dispatch.agent` is configured (the SAME opt-in as the session-start auto-publish), piggybacking on write-activity but THROTTLED to one ping per `dispatch.heartbeatIntervalMs` (default 5min) — so a long session keeps `last_seen` fresh between session-starts (which today refresh it the expensive way, via a full re-publish) without re-probing. Bounded + fail-open; disable with `SPOR_HEARTBEAT=0` |
354
+ | `GET /v1/profiles/{id}/hosts` `?owner=me\|person-X&max_age=<dur>` | `spor capabilities hosts <profile>`; `spor dispatch` (auto on a FORK B refusal) | **host-match** a `type: profile` against every agent's published capabilities using the SAME pure `satisfies()` matcher the client runs locally → `200 {profile, satisfiable: [{agent, owner, published_at, last_seen, age_seconds}], unsatisfiable: [{agent, owner, published_at, last_seen, age_seconds, reasons}], counts}`. Satisfiable hosts are freshest-first (by `last_seen`); the unsatisfiable carry the matcher's own reasons (the failing atoms), enabling **substitution-free re-routing** — pick a box that satisfies the profile, NEVER substitute a different one (dec-spor-machine-profile-satisfiability FORK B). The CLIENT consumer (task-spor-fleet-scheduler-autoroute-dispatch): `spor capabilities hosts` lists the re-route targets directly, and when `spor dispatch` refuses because THIS box can't satisfy the resolved profile it calls this endpoint and names the satisfiable hosts to re-route to — or, when none satisfy it, escalates to the owner (fail-soft: an unreachable scheduler degrades to a generic hint). **Visibility is steward-scoped** (task-spor-fleet-scheduler-hardening): the whole-fleet view (every member's boxes + caps) is a multi-tenant cross-member disclosure, so an **admin** (stewards→root) sees the whole fleet and may scope to any `owner=person-X`, while an ordinary **member** is scoped to THEIR OWN boxes (default `owner` = the caller's person; an agent token resolves to its owner; `owner=me` is the explicit form) and a member asking for a colleague's `owner=person-X` is `403`. `max_age` (`30m`/`12h`/`7d`/ms) demotes hosts whose `last_seen` is older than it to unsatisfiable (the liveness filter). `404` unknown/non-profile id; `422` bad `max_age`/`owner` |
339
355
 
340
356
  Path parameters (node ids, project slugs) must match
341
357
  `^[a-z0-9][a-z0-9-]*$`. Request bodies are capped at 1MB
@@ -417,6 +433,33 @@ anything with a token.
417
433
  `{name, email}` attribution record. Access tokens are `spor_oat_…` (30d;
418
434
  legacy `sub_oat_…` accepted); refresh tokens are `spor_ort_…` (90d,
419
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).
420
463
  - **Render tickets (shared lens links).** `POST /v1/lens/{id}/ticket` (§3)
421
464
  mints a signed, expiring, **read-only** ticket carrying `{lens_id,
422
465
  sharer_person_id, exp}` — the credential a *shared* view link carries instead
@@ -465,10 +508,15 @@ spellings are still read — dual-read back-compat window); unset means local
465
508
  mode, reading `$SPOR_HOME` directly:
466
509
 
467
510
  ```
468
- SPOR_SERVER=https://spor.example.com
511
+ SPOR_SERVER=https://api.sporhq.io # hosted Spor REST base (the onboarding default)
469
512
  SPOR_TOKEN=spor_pat_... # per-user token (§4)
513
+ SPOR_ORG=acme # select a stored tenant by org (§6.2)
470
514
  ```
471
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
+
472
520
  **Opt-in activation.** Spor is opt-in per repo: with the plugin installed, the
473
521
  hooks are a full no-op (no context injected, nothing distilled) in any repo that
474
522
  has not opted in. A repo is active when its mode is not `off` AND either an
@@ -537,3 +585,41 @@ Contract:
537
585
  `nodes/` and brief `history/` are committed. The SessionEnd distiller leaves
538
586
  distilled nodes **uncommitted** (for the human PR flow) instead of
539
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
@@ -617,9 +637,10 @@ The layer ABOVE agent identity (dec-spor-agent-orchestration-layer): how work is
617
637
  routed to agents, and how per-person automation fires on graph events. The node
618
638
  model is `person ──owns──▶ agent ──uses-profile──▶ profile`, with owner-scoped
619
639
  `routine` nodes driving automation. It composes with — never bypasses — the org
620
- policy layer. The schemas ship in the seed pack; the dispatch matcher, the
621
- routine engine, and the remote fleet scheduler are deferred
622
- (task-spor-dispatch-capabilities-satisfiability).
640
+ policy layer. The schemas ship in the seed pack, and so do the machine-local
641
+ `dispatch.capabilities` map + `satisfies()` matcher + fail-soft dispatch
642
+ (task-spor-dispatch-capabilities-satisfiability); the routine engine and the
643
+ remote fleet scheduler are still deferred.
623
644
 
624
645
  ### profile — the reusable runtime+capability bundle
625
646
 
@@ -656,8 +677,13 @@ date: 2026-06-18
656
677
  machine.reachable_mcp ∧ profile.skills ⊆ machine.skills ∧ profile.plugins ⊆
657
678
  machine.plugins ∧ profile ∉ machine.deny`. No satisfying machine → dispatch
658
679
  fails soft and LOUD, leaves the assignment intact, NEVER substitutes a
659
- different profile. Forward-compatible with the deferred remote fleet scheduler
660
- (each agent publishes its capabilities; same vocabulary).
680
+ different profile. The probe seeds `reachable_mcp: [spor]` from CONFIGURED-ness
681
+ (a bound Spor server/connector, remote mode) rather than a network ping — the
682
+ agent-spor server is part of every dispatched session's toolset by construction
683
+ (above), so an `mcp: [spor]` profile satisfies on a fresh box with no manual
684
+ `allow-mcp` (task-spor-mcp-reachability-deterministic-seed). Forward-compatible
685
+ with the deferred remote fleet scheduler (each agent publishes its capabilities;
686
+ same vocabulary).
661
687
  - **Reusable + both-scoped, with override.** Profiles are PERSONAL and
662
688
  ORG-PUBLISHED (a curated, vetted toolset), with personal override. Org-published
663
689
  profiles are where this meets policy: a policy can require that work of a risk
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