@sporhq/spor 0.7.1 → 0.9.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.7.1",
5
+ "version": "0.9.0",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/API.md CHANGED
@@ -22,7 +22,11 @@ graph's git repo. What a client sees:
22
22
  and `session: <id>` and uses `authored_via: dispatch`, while `author:` stays
23
23
  the agent's **owning person** — so the node reads "agent on behalf of person".
24
24
  These ride-along fields are token-derived too; any supplied in the payload are
25
- discarded.
25
+ discarded. The `session` is the agent's REAL run session: `spor dispatch` mints
26
+ the token **session-deferred** (it can't know the session before `claude --bg`
27
+ self-allocates it) and binds the real one post-launch via `POST /v1/agents/session`
28
+ (§3, dec-spor-dispatch-bg-session-late-bind) — so writes BEFORE the bind carry no
29
+ `session` (honest, never a phantom), and writes after trace to the actual run.
26
30
  - **Create**: `if_exists: "skip"` → id collision is reported as `skipped`
27
31
  (the distiller default); `if_exists: "error"` → id collision is a
28
32
  `conflict` error.
@@ -301,8 +305,8 @@ endpoint is the REST twin of a core call:
301
305
  | `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 |
302
306
  | `POST /v1/nodes/{id}/edges` `{type, to}` | scripts, mechanical writers | `add_edge` semantics (§1): normalize/flip, dedupe, append — no revision echo |
303
307
  | `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) |
304
- | `POST /v1/nodes/{id}/claim` `{session?}` | `claim`/`set_status` MCP tools, `spor dispatch` | take the heartbeat-renewed lease (dec-cc-task-claim-lease): writes the durable `assigned` edge once, attributes to `$viewer` from the token (never an argument), and creates the ephemeral lease → `{ok, status, lease: {node_id, by, expires, expires_at, session, claimed_at}, edge}`. A live lease held by ANOTHER person is `409 conflict` naming the holder + expiry (re-claiming your OWN live claim just renews it). `session` scopes the heartbeat (omit to leave it person-scoped, so any of the claimer's sessions may renew — what `spor dispatch` does, since the launched agent renews from a different session) |
305
- | `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool | bump the live lease's expiry only — no commit; the heartbeat that keeps a claim from lapsing. A lapsed/stolen lease is `409` (names the current holder). Person-scoped: any of the claimer's sessions may renew |
308
+ | `POST /v1/nodes/{id}/claim` `{session?}` | `claim`/`set_status` MCP tools, `spor dispatch` | take the heartbeat-renewed lease (dec-cc-task-claim-lease): writes the durable `assigned` edge once, attributes to `$viewer` from the token (never an argument), and creates the ephemeral lease → `{ok, status, lease: {node_id, by, expires, expires_at, session, claimed_at}, edge}`. A live lease held by ANOTHER person is `409 conflict` naming the holder + expiry (re-claiming your OWN live claim just renews it). `session` scopes the heartbeat (omit to leave it person-scoped, so any of the claimer's sessions may renew — what `spor dispatch` does at the PRE-launch claim, since `claude --bg` self-allocates the run session only at launch; dispatch then renews with the real session once it has read it from `claude agents --json`, dec-spor-dispatch-bg-session-late-bind) |
309
+ | `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool, `spor dispatch` | bump the live lease's expiry only — no commit; the heartbeat that keeps a claim from lapsing. A lapsed/stolen lease is `409` (names the current holder). Person-scoped: any of the claimer's sessions may renew; a `session` binds the lease to that run (`spor dispatch` uses this to bind the captured `claude --bg` session post-launch) |
306
310
  | `POST /v1/nodes/{id}/release` | `release` MCP tool | drop the lease AND retire the durable `assigned` edge, returning the node to the pool. Idempotent (releasing a node you hold no lease on still succeeds, cleaning up any lingering `assigned` edge of yours); releasing a claim someone else holds is `409` naming the holder |
307
311
  | `POST /v1/nodes/{id}/commits` `{repo, sha}` | post-tool / link-commits | `link_commit`: append `repo@sha` to the node's `commits:` list (kebab-case repo slug, 7–40 lowercase hex, ≤40 commits per node); idempotent, prefix-aware dedup |
308
312
  | `GET /v1/commits/{sha}?repo=` | sessions doing git archaeology | sha → nodes lookup over the `commits:` fields (≥7 hex, abbreviated or full); each match carries `{repo, sha, id, type, title, summary, status, project}` — blame a line, get the why |
@@ -321,7 +325,8 @@ endpoint is the REST twin of a core call:
321
325
  | `DELETE /v1/admin/tokens/{hash-prefix}` | offboarding / rotation | revoke the single PAT matching the hash prefix (≥8 hex chars; an ambiguous prefix is a 409) → `{revoked, hash_prefix}`. Admin-only |
322
326
  | `GET /v1/agents` | `spor agent list` | list the agents the caller **owns** → `{agents: [{id, label, owner, spiffe, pubkey, status}], count}`; `?all=1` lists every agent (admin-only) |
323
327
  | `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` |
324
- | `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}`. Authorized iff the caller's person **owns** the agent (its `owned-by` edge) — else `403`; `404` unknown agent; `422` bad/missing `session`. 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 |
328
+ | `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 |
329
+ | `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 |
325
330
 
326
331
  Path parameters (node ids, project slugs) must match
327
332
  `^[a-z0-9][a-z0-9-]*$`. Request bodies are capped at 1MB
@@ -378,8 +383,20 @@ anything with a token.
378
383
  background agent (so the agent's own graph writes carry its identity), picking
379
384
  the machine's default agent from the `dispatch.agent` client config (set with
380
385
  `spor agent use <agent-id>`, or `SPOR_DISPATCH_AGENT`), which `spor dispatch
381
- --as <agent-id>` overrides for a single run. (Not to be confused with `spor
382
- dispatch --agent`, the unrelated `claude --agent` harness passthrough.)
386
+ --as <agent-id>` overrides for a single run. The `<agent-id>` is the agent's
387
+ `agent-`-prefixed NODE id (what `spor agent list` prints), **not** its bare
388
+ label — the token endpoint requires the prefix, so the client setters
389
+ (`spor agent use`, `--as`) reject a prefix-less id with a `did you mean
390
+ agent-…?` hint rather than persist one every dispatch would 422 on. (Not to be
391
+ confused with `spor dispatch --agent`, the unrelated `claude --agent` harness
392
+ passthrough.) The
393
+ token is minted **session-deferred** and bound to the real run session AFTER
394
+ launch (dec-spor-dispatch-bg-session-late-bind): `claude --bg` ignores
395
+ `--session-id` and self-allocates its session, so dispatch reads the real one
396
+ from `claude agents --json` and binds it via `POST /v1/agents/session` (§3) — the
397
+ one place an agent token's session is set, write-once. The session can't be
398
+ forged a-priori (it isn't known until the run exists) and can't ride the write
399
+ payload (token-derived, §1), so the binding is always the actual run.
383
400
  - **OAuth 2.1 for MCP connectors** (Cowork/claude.ai, which cannot carry a
384
401
  static bearer token): protected-resource metadata discovery (RFC 9728,
385
402
  advertised on the `/mcp` 401 via `WWW-Authenticate`), authorization-server
package/bin/spor.js CHANGED
@@ -418,7 +418,9 @@ function dispatchedAgents() {
418
418
  if (!a || a.kind !== "background" || typeof a.name !== "string") continue;
419
419
  if (a.state === "done") continue; // finished — not in flight
420
420
  const list = map.get(a.name) || [];
421
- list.push({ id: a.id, name: a.name, state: a.state, status: a.status, cwd: a.cwd });
421
+ // sessionId + startedAt ride along for post-launch session capture
422
+ // (dec-spor-dispatch-bg-session-late-bind); the dup-guard ignores them.
423
+ list.push({ id: a.id, name: a.name, state: a.state, status: a.status, cwd: a.cwd, sessionId: a.sessionId, startedAt: a.startedAt });
422
424
  map.set(a.name, list);
423
425
  }
424
426
  return map;
@@ -427,6 +429,40 @@ function dispatchedAgents() {
427
429
  }
428
430
  }
429
431
 
432
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
433
+
434
+ // Find the FULL run-session id of the agent `spor dispatch` just launched in `dir`
435
+ // (dec-spor-dispatch-bg-session-late-bind). `claude --bg` self-allocates and
436
+ // prints only a SHORT id, but `claude agents --json` reports the full `sessionId`
437
+ // + `cwd` + `startedAt` — the reliable capture path. Match on cwd (the strong
438
+ // signal — we just launched there), then on name when given, then pick the NEWEST
439
+ // (the run we just started). Returns the sessionId or null.
440
+ function newestDispatchedSession(name, dir) {
441
+ const all = [];
442
+ for (const arr of dispatchedAgents().values()) for (const a of arr) all.push(a);
443
+ let cands = all.filter((a) => a.sessionId && (!dir || a.cwd === dir));
444
+ if (name) {
445
+ const named = cands.filter((a) => a.name === name);
446
+ if (named.length) cands = named;
447
+ }
448
+ if (!cands.length) return null;
449
+ cands.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
450
+ return cands[0].sessionId;
451
+ }
452
+
453
+ // Capture the launched run's session, polling briefly while the daemon registers
454
+ // it. SPOR_SESSION_ID pins it (tests/reproducibility) and short-circuits the poll.
455
+ // Returns the sessionId or null (fail-open — the caller degrades to session-null).
456
+ async function captureDispatchSession(name, dir, pinned) {
457
+ if (pinned) return pinned;
458
+ for (let i = 0; i < 6; i++) {
459
+ const sid = newestDispatchedSession(name, dir);
460
+ if (sid) return sid;
461
+ await sleep(300);
462
+ }
463
+ return null;
464
+ }
465
+
430
466
  // Stamp items[].in_flight from the dispatched-agent map, optionally dropping the
431
467
  // in-flight ones (--hide-dispatched). Every kept item gets an in_flight boolean
432
468
  // (so the flag is present on all of them — claude absent => uniformly false);
@@ -984,6 +1020,28 @@ async function cmdAgent(cfg, args) {
984
1020
  return 1;
985
1021
  }
986
1022
 
1023
+ // A valid Spor agent id, mirroring the server's token-mint contract EXACTLY
1024
+ // (spor-server server/rest.js: `SLUG_RE.test(id) && id.startsWith("agent-")`).
1025
+ // The `agent-` prefix is load-bearing: the agent NODE id carries it, but the
1026
+ // `spor agent create`/`list` output also prints the bare LABEL, so copying the
1027
+ // label into `spor agent use`/`dispatch --as` is an easy slip — and the bare
1028
+ // slug passes a plain kebab check while the server's POST /v1/agents/{id}/token
1029
+ // 422s on it (invalid_node), silently dropping the dispatch to person-scoped.
1030
+ // One predicate, used by every client setter that feeds that endpoint, so the
1031
+ // client never accepts an id the server rejects
1032
+ // (issue-spor-dispatch-agent-id-prefix-validation-gap).
1033
+ function isAgentId(id) {
1034
+ return typeof id === "string" && /^[a-z0-9][a-z0-9-]*$/.test(id) && id.startsWith("agent-");
1035
+ }
1036
+
1037
+ // When a rejected id is a valid kebab slug that merely DROPPED the `agent-`
1038
+ // prefix (the common label-vs-id slip), suggest the prefixed form; else null.
1039
+ function agentIdGuess(id) {
1040
+ return typeof id === "string" && /^[a-z0-9][a-z0-9-]*$/.test(id) && !id.startsWith("agent-")
1041
+ ? `agent-${id}`
1042
+ : null;
1043
+ }
1044
+
987
1045
  // `spor agent use <agent-id>` — make this agent the machine's default dispatch
988
1046
  // identity by writing `dispatch.agent` to the USER config.json (the same
989
1047
  // machine-local, never-committed file as the repo map; per-machine, like
@@ -997,8 +1055,10 @@ function cmdAgentUse(cfg, { id }) {
997
1055
  err("usage: spor agent use <agent-id> (or: spor agent use --clear)");
998
1056
  return 1;
999
1057
  }
1000
- if (!clear && !/^[a-z0-9][a-z0-9-]*$/.test(id)) {
1001
- err(`invalid agent id '${id}' — must match ^[a-z0-9][a-z0-9-]*$ (e.g. agent-your-machine)`);
1058
+ if (!clear && !isAgentId(id)) {
1059
+ err(`invalid agent id '${id}' — must be an 'agent-<slug>' kebab id (e.g. agent-your-machine)`);
1060
+ const guess = agentIdGuess(id);
1061
+ if (guess) err(` did you mean '${guess}'? ('spor agent list' shows the full id — the 'agent-' prefix is part of it, not the label)`);
1002
1062
  return 1;
1003
1063
  }
1004
1064
  const home = cfg.userConfigHome();
@@ -2056,17 +2116,16 @@ async function topQueueItem(cfg, slug) {
2056
2116
  // post-tool heartbeat drives (dec-cc-task-claim-lease, task-cc-claim-nudge-hook).
2057
2117
  // REMOTE-MODE ONLY: a claim is a server-held lease; local mode has no pool or
2058
2118
  // contention (dec-cc-task-claim-lease "Local mode"), so the caller skips it and
2059
- // local dispatch stays byte-identical. SESSION-BOUND at dispatch
2060
- // (dec-spor-session-identity-active-record): dispatch now FORCES the session id
2061
- // (`--session-id <uuid>`), so it knows the working session up front and binds the
2062
- // lease to it; the launched bg agent runs under that exact session, so its
2063
- // post-tool heartbeat renews the same-session lease (the earlier person-scoped /
2064
- // session:null workaround existed only because the old dispatcher did NOT know
2065
- // the working session that reason is gone). Omitting `session` leaves the lease
2066
- // person-scoped, the prior behavior. Returns {ok} on success/idempotent-renew,
2067
- // {conflict, message} when the node is already held (the concurrent-dispatch case
2068
- // this guards), or {error} for any other failure (fail-open: the caller warns and
2069
- // dispatches anyway).
2119
+ // local dispatch stays byte-identical. PRE-LAUNCH the claim is PERSON-SCOPED
2120
+ // (session omitted, dec-spor-dispatch-bg-session-late-bind): `claude --bg`
2121
+ // IGNORES `--session-id` and self-allocates its real session, so the working
2122
+ // session is NOT knowable up front binding the lease to a forced uuid was a
2123
+ // phantom (issue-spor-dispatch-bg-ignores-forced-session-id). Dispatch instead
2124
+ // captures the real session post-launch and binds it via renewDispatch (and the
2125
+ // bg agent's own post-tool heartbeat renews the same-session lease thereafter).
2126
+ // Returns {ok} on success/idempotent-renew, {conflict, message} when the node is
2127
+ // already held (the concurrent-dispatch case this guards), or {error} for any
2128
+ // other failure (fail-open: the caller warns and dispatches anyway).
2070
2129
  async function claimDispatch(cfg, nodeId, session) {
2071
2130
  const body = session ? { session } : {};
2072
2131
  const r = await remote.post(cfg, `/v1/nodes/${encodeURIComponent(nodeId)}/claim`, body, { timeoutMs: 6000 });
@@ -2088,6 +2147,35 @@ async function claimDispatch(cfg, nodeId, session) {
2088
2147
  return { ok: false, error: r.transport ? r.error : `HTTP ${r.status}${code ? ` (${code})` : ""}` };
2089
2148
  }
2090
2149
 
2150
+ // Renew the dispatch lease, binding it to the REAL session captured post-launch
2151
+ // (dec-spor-dispatch-bg-session-late-bind). The pre-launch claim was person-scoped;
2152
+ // this binds the lease's session to the real `claude --bg` run so the lease and the
2153
+ // rebound agent token agree from the start (instead of waiting for the agent's first
2154
+ // heartbeat to self-heal it). Best-effort: a lapsed/stolen lease (409) or any other
2155
+ // failure is swallowed — the bg agent's heartbeat still renews it. Returns {ok}.
2156
+ async function renewDispatch(cfg, nodeId, session) {
2157
+ const r = await remote.post(cfg, `/v1/nodes/${encodeURIComponent(nodeId)}/renew`, { session }, { timeoutMs: 3000 });
2158
+ return { ok: !!r.ok };
2159
+ }
2160
+
2161
+ // Late-bind the agent token's run session (dec-spor-dispatch-bg-session-late-bind).
2162
+ // The token was minted session-DEFERRED before launch (the session wasn't knowable
2163
+ // yet); this reports the REAL session captured from `claude agents --json`,
2164
+ // authenticated by the AGENT TOKEN ITSELF (not the person token) so the server can
2165
+ // set it on that token's record. Every subsequent write under the token then stamps
2166
+ // the real session. Best-effort/fail-open: a server without the route (404), a
2167
+ // conflict (409), or any transport error leaves the token session-null (writes carry
2168
+ // no session — honest, never a phantom) rather than blocking dispatch. Returns
2169
+ // {ok}|{absent}|{conflict}|{error}.
2170
+ async function bindAgentSession(cfg, agentToken, session) {
2171
+ const r = await remote.post(cfg, `/v1/agents/session`, { session }, { timeoutMs: 3000, token: agentToken });
2172
+ if (r.ok) return { ok: true };
2173
+ if (r.status === 404) return { ok: false, absent: true };
2174
+ if (r.status === 409) return { ok: false, conflict: true };
2175
+ const code = r.json && r.json.error && r.json.error.code;
2176
+ return { ok: false, error: r.transport ? r.error : `HTTP ${r.status}${code ? ` (${code})` : ""}` };
2177
+ }
2178
+
2091
2179
  // Resolve the directory to launch in. --dir wins; else a known slug is looked up
2092
2180
  // in the map; else the cwd's durable repo root. { dir:null } means "slug unknown
2093
2181
  // here". The cwd fallback uses dispatchRoot() (not repoRoot()) so a dispatch run
@@ -2190,13 +2278,18 @@ function dispatchAgentId(cfg) {
2190
2278
  // OWNERSHIP-gated, NOT admin-gated: POST /v1/agents/{id}/token authenticated with
2191
2279
  // the dispatcher's normal person token (SPOR_TOKEN); the server checks the caller
2192
2280
  // OWNS agent {id} (the owned-by edge) — so a normal teammate can mint a token for
2193
- // their own machine's agent without being an admin. REMOTE only. Returns
2194
- // { ok, token } on success, { absent:true } when the mint surface isn't deployed
2195
- // yet (404 — the spor-server stream is building it; fail soft, dispatch without
2196
- // agent-scoping), or { error } on any other failure incl. 403/owner-mismatch
2197
- // (also fail soft warn and dispatch person-scoped, never block).
2281
+ // their own machine's agent without being an admin. REMOTE only. The token is
2282
+ // minted session-DEFERRED (session omitted) when the real session isn't yet known
2283
+ // — the standing case, since `claude --bg` allocates it only at launch
2284
+ // (dec-spor-dispatch-bg-session-late-bind); dispatch binds the real session
2285
+ // afterward via bindAgentSession. The `session` param is kept for a caller that
2286
+ // genuinely knows it up front (none today — dispatch always defers). Returns
2287
+ // { ok, token } on success, { absent:true }
2288
+ // when the mint surface isn't deployed yet (404 — fail soft, dispatch person-
2289
+ // scoped), or { error } on any other failure incl. 403/owner-mismatch (also fail
2290
+ // soft — warn and dispatch person-scoped, never block).
2198
2291
  async function mintAgentToken(cfg, { agent, session }) {
2199
- const r = await remote.post(cfg, `/v1/agents/${encodeURIComponent(agent)}/token`, { session }, { timeoutMs: 6000 });
2292
+ const r = await remote.post(cfg, `/v1/agents/${encodeURIComponent(agent)}/token`, session ? { session } : {}, { timeoutMs: 6000 });
2200
2293
  if (r.transport) return { error: r.error };
2201
2294
  // 404 = no route (surface not deployed) => absent, dispatch falls back cleanly.
2202
2295
  if (r.status === 404) return { absent: true };
@@ -2209,16 +2302,17 @@ async function mintAgentToken(cfg, { agent, session }) {
2209
2302
  // Write the 0600 --mcp-config JSON that gives the bg agent ONLY its own
2210
2303
  // agent-scoped Spor MCP (account connector excluded by --strict-mcp-config,
2211
2304
  // verified #1). Machine-local, gitignored-adjacent path under the user config
2212
- // home's outbox; per-session filename so concurrent dispatches don't collide.
2213
- // Returns the file path. The bg agent reads it on startup AFTER this process
2214
- // exits (claude --bg detaches), so we cannot delete it eagerly — cleanup is a
2215
- // best-effort sweep of stale files here, plus the documented short-TTL token
2216
- // inside it (dec-spor-session-identity-active-record open item).
2217
- function writeDispatchMcpConfig(cfg, { token, session }) {
2305
+ // home's outbox; per-dispatch filename (`key`, a fresh uuid) so concurrent
2306
+ // dispatches don't collide the session id is no longer known at this point
2307
+ // (deferred until post-launch, dec-spor-dispatch-bg-session-late-bind). Returns
2308
+ // the file path. The bg agent reads it on startup AFTER this process exits (claude
2309
+ // --bg detaches), so we cannot delete it eagerly — cleanup is a best-effort sweep
2310
+ // of stale files here, plus the documented short-TTL token inside it.
2311
+ function writeDispatchMcpConfig(cfg, { token, key }) {
2218
2312
  const dir = path.join(cfg.userConfigHome(), "outbox", "dispatch");
2219
2313
  fs.mkdirSync(dir, { recursive: true });
2220
2314
  sweepStaleMcpConfigs(dir);
2221
- const file = path.join(dir, `mcp-${session}.json`);
2315
+ const file = path.join(dir, `mcp-${key}.json`);
2222
2316
  const conf = {
2223
2317
  mcpServers: {
2224
2318
  spor: {
@@ -2231,8 +2325,8 @@ function writeDispatchMcpConfig(cfg, { token, session }) {
2231
2325
  // 0600: the file holds a live bearer token. Create with O_EXCL (wx) so a
2232
2326
  // pre-placed file or symlink at this path is REFUSED rather than written
2233
2327
  // through, and the file is 0600 from creation (no widen-then-narrow window).
2234
- // The session-uuid filename makes a real collision a non-issue; a stale
2235
- // leftover was swept above.
2328
+ // The uuid filename makes a real collision a non-issue; a stale leftover was
2329
+ // swept above.
2236
2330
  const fd = fs.openSync(file, "wx", 0o600);
2237
2331
  try {
2238
2332
  fs.writeSync(fd, JSON.stringify(conf, null, 2) + "\n");
@@ -2396,23 +2490,45 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2396
2490
  // exit / unparseable output => empty => no guard (fail-open); --force overrides.
2397
2491
  const inFlight = nodeId && !backfill ? dispatchedAgents().get(name) || [] : [];
2398
2492
 
2399
- // Session identity (dec-spor-session-identity-active-record). Force the
2400
- // session id up front `--session-id <uuid>` makes the dispatched run's
2401
- // session deterministic, so the agent-scoped token below can bind it AND the
2402
- // claim can be session-bound at dispatch (the bg agent's post-tool heartbeat
2403
- // renews under this same id). SPOR_SESSION_ID pins it for tests/reproducibility.
2404
- const session = process.env.SPOR_SESSION_ID || crypto.randomUUID();
2493
+ // Session identity (dec-spor-dispatch-bg-session-late-bind). `claude --bg`
2494
+ // IGNORES `--session-id` and self-allocates its own run session (verified it
2495
+ // warns and ignores the flag), so we do NOT force one and the session is NOT
2496
+ // knowable up front. The agent token is minted session-DEFERRED; the real
2497
+ // session is captured from `claude agents --json` AFTER launch and bound then
2498
+ // (rebind the token + renew the lease). SPOR_SESSION_ID pins the session for
2499
+ // tests/reproducibility (short-circuits the capture). `mcpKey` names the 0600
2500
+ // --mcp-config file — a fresh uuid, since the session id isn't available here.
2501
+ const pinnedSession = process.env.SPOR_SESSION_ID || null;
2502
+ const mcpKey = crypto.randomUUID();
2405
2503
  // This machine's agent node — the WHO a dispatched session runs as. `--as`
2406
- // overrides the per-machine dispatch.agent default for this one dispatch (its
2407
- // id format mirrors a node id; a bad/unowned one fails soft at token-mint
2408
- // below, like the config default). Only meaningful remotely (the server is the
2409
- // CA that mints the agent token); a local-mode dispatch or an unconfigured
2410
- // machine simply runs person-scoped.
2411
- if (asAgent && !/^[a-z0-9][a-z0-9-]*$/.test(asAgent)) {
2412
- err(`invalid --as agent id '${asAgent}' — must match ^[a-z0-9][a-z0-9-]*$ (e.g. agent-your-machine)`);
2504
+ // overrides the per-machine dispatch.agent default for this one dispatch. The
2505
+ // id must satisfy the SAME contract the server's token-mint endpoint enforces
2506
+ // (an 'agent-<slug>' kebab id) an EXPLICIT --as that doesn't is a hard error
2507
+ // here, caught before any side effect rather than as a per-dispatch 422
2508
+ // (issue-spor-dispatch-agent-id-prefix-validation-gap). Only meaningful remotely
2509
+ // (the server is the CA that mints the agent token); a local-mode dispatch or an
2510
+ // unconfigured machine simply runs person-scoped.
2511
+ if (asAgent && !isAgentId(asAgent)) {
2512
+ err(`invalid --as agent id '${asAgent}' — must be an 'agent-<slug>' kebab id (e.g. agent-your-machine)`);
2513
+ const guess = agentIdGuess(asAgent);
2514
+ if (guess) err(` did you mean '--as ${guess}'? ('spor agent list' shows the full id — the 'agent-' prefix is part of it, not the label)`);
2413
2515
  return 1;
2414
2516
  }
2415
- const identityAgent = cfg.mode() === "remote" ? (asAgent || dispatchAgentId(cfg)) : null;
2517
+ let identityAgent = cfg.mode() === "remote" ? (asAgent || dispatchAgentId(cfg)) : null;
2518
+ // A configured `dispatch.agent` (no --as) that isn't a valid agent id — e.g. the
2519
+ // agent's LABEL stored instead of its 'agent-'-prefixed NODE id — would 422 at
2520
+ // token-mint and silently fall back to person-scoped on EVERY dispatch, quietly
2521
+ // defeating agent attribution (issue-spor-dispatch-agent-id-prefix-validation-gap).
2522
+ // Catch it here with an actionable line and run person-scoped, rather than a
2523
+ // round-trip to a 422 that names nothing. Fail-soft (don't block the dispatch):
2524
+ // the explicit --as path already hard-errored above, so this only fires for the
2525
+ // config default.
2526
+ if (identityAgent && !isAgentId(identityAgent)) {
2527
+ err(`warning: configured dispatch.agent '${identityAgent}' is not a valid agent id — dispatching person-scoped.`);
2528
+ const guess = agentIdGuess(identityAgent);
2529
+ err(` agent ids start with 'agent-'.${guess ? ` fix: spor agent use ${guess}` : ""} ('spor agent list' shows your agents.)`);
2530
+ identityAgent = null;
2531
+ }
2416
2532
  // An explicit --as can't take effect in local mode — there is no CA to mint the
2417
2533
  // agent token. Say so rather than silently dropping it to person-scoped.
2418
2534
  if (asAgent && cfg.mode() !== "remote") {
@@ -2425,9 +2541,8 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2425
2541
  if (model) claudeArgs.push("--model", model);
2426
2542
  if (permMode) claudeArgs.push("--permission-mode", permMode);
2427
2543
  if (agent) claudeArgs.push("--agent", agent);
2428
- // Force the session id on every dispatch (local or remote). It is the run leaf
2429
- // of the agent's identity and what the session-bound claim + heartbeat key on.
2430
- claudeArgs.push("--session-id", session);
2544
+ // NB: no `--session-id` `claude --bg` ignores it (warns) and manages its own
2545
+ // session; we capture the real one post-launch (dec-spor-dispatch-bg-session-late-bind).
2431
2546
 
2432
2547
  if (dryRun) {
2433
2548
  out(`dir: ${res.dir} (slug: ${res.slug}, via ${res.source})`);
@@ -2439,7 +2554,7 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2439
2554
  out(`onboard: ${steps.join("; ")}`);
2440
2555
  }
2441
2556
  out(`brief: ${brief ? `${brief.length} bytes` : "(none — graph had nothing relevant, or --no-brief/--backfill)"}`);
2442
- out(`session: ${session}`);
2557
+ out(`session: ${pinnedSession || "(allocated by claude --bg at launch, bound after)"}`);
2443
2558
  // Identity preview: what the real dispatch would do for agent-scoping. The
2444
2559
  // token mint + 0600 mcp-config are SIDE EFFECTS, so --print only describes
2445
2560
  // them (it writes nothing and makes no network call here). Local mode and an
@@ -2447,7 +2562,7 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2447
2562
  // session line, which is additive and always present now.
2448
2563
  if (identityAgent) {
2449
2564
  const src = asAgent ? " (via --as)" : "";
2450
- out(`agent: ${identityAgent}${src} (would mint a per-session agent-scoped token + write a 0600 --mcp-config, then add --strict-mcp-config)`);
2565
+ out(`agent: ${identityAgent}${src} (would mint a session-deferred agent-scoped token + write a 0600 --mcp-config, add --strict-mcp-config, then bind the run session after launch)`);
2451
2566
  } else if (cfg.mode() === "remote") {
2452
2567
  out(`agent: (none configured — 'spor agent use agent-<machine>' or --as to attribute as agent-on-behalf-of; dispatching person-scoped)`);
2453
2568
  }
@@ -2463,7 +2578,7 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2463
2578
  // Auto-claim preview (remote node dispatch only — local mode has no lease, so
2464
2579
  // nothing is announced there and local --print stays byte-identical).
2465
2580
  if (nodeId && !backfill && cfg.mode() === "remote") {
2466
- out(`claim: ${noClaim ? "(--no-claim — lease not established)" : `would establish a session-bound lease on ${nodeId} at launch`}`);
2581
+ out(`claim: ${noClaim ? "(--no-claim — lease not established)" : `would establish a lease on ${nodeId} at launch (session bound from the run after launch)`}`);
2467
2582
  }
2468
2583
  if (template != null) out(`template: ${path.resolve(templateOpt)}`);
2469
2584
  out(`run: ${claudeBin} ${claudeArgs.map(shellQuote).join(" ")} <prompt>`);
@@ -2500,21 +2615,35 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2500
2615
  // the VERIFIED mechanism): mint a per-session agent-scoped token, write it into
2501
2616
  // a 0600 --mcp-config that exposes ONLY the agent's own Spor MCP, and add
2502
2617
  // --strict-mcp-config so the account connector is excluded by construction. The
2503
- // server then stamps authored_by_agent + session from that token. FAIL SOFT at
2618
+ // server then stamps authored_by_agent + session from that token. The token is
2619
+ // minted session-DEFERRED — the run session isn't known until `claude --bg`
2620
+ // self-allocates it, so we bind it AFTER launch (dec-spor-dispatch-bg-session-
2621
+ // late-bind), keeping `agentToken` to authenticate that late bind. FAIL SOFT at
2504
2622
  // every step — a server without the mint surface, or a transient error, falls
2505
2623
  // back to the prior person-scoped dispatch with a clear line. Remote + a
2506
- // configured agent only; local/unconfigured dispatch is byte-identical but for
2507
- // the always-present --session-id.
2624
+ // configured agent only; local/unconfigured dispatch is byte-identical.
2625
+ let agentToken = null;
2508
2626
  if (identityAgent) {
2509
- const mint = await mintAgentToken(cfg, { agent: identityAgent, session });
2627
+ // Always session-DEFERRED the run session is bound after launch (below),
2628
+ // even when SPOR_SESSION_ID pins it (the pin feeds the capture, not the mint),
2629
+ // so the bind path is uniform.
2630
+ const mint = await mintAgentToken(cfg, { agent: identityAgent });
2510
2631
  if (mint.ok) {
2511
- const mcpFile = writeDispatchMcpConfig(cfg, { token: mint.token, session });
2632
+ agentToken = mint.token;
2633
+ const mcpFile = writeDispatchMcpConfig(cfg, { token: mint.token, key: mcpKey });
2512
2634
  claudeArgs.push("--mcp-config", mcpFile, "--strict-mcp-config");
2513
- out(`agent: ${identityAgent} (session ${session} — writes attributed agent-on-behalf-of-you)`);
2635
+ out(`agent: ${identityAgent} (writes attributed agent-on-behalf-of-you; run session bound after launch)`);
2514
2636
  } else if (mint.absent) {
2515
- err(`warning: this server can't mint agent-scoped session tokens yet — dispatching person-scoped (session ${session}).`);
2637
+ err(`warning: this server can't mint agent-scoped session tokens yet — dispatching person-scoped.`);
2516
2638
  } else {
2517
- err(`warning: could not mint an agent token (${mint.error})dispatching person-scoped (session ${session}).`);
2639
+ // Name the offending agent and the fix a bare "(HTTP 422 …)" tells the
2640
+ // operator nothing about WHICH id is wrong or how to repair it. The format
2641
+ // gate is now caught client-side above, so a 422 here means the id is a
2642
+ // well-formed 'agent-<slug>' the server still rejected (e.g. no such agent /
2643
+ // not owned); point at the list either way
2644
+ // (issue-spor-dispatch-agent-id-prefix-validation-gap).
2645
+ err(`warning: could not mint an agent token for ${identityAgent} (${mint.error}) — dispatching person-scoped.`);
2646
+ err(` check it exists and you own it: spor agent list (set this machine's default with: spor agent use <agent-id>)`);
2518
2647
  }
2519
2648
  }
2520
2649
 
@@ -2523,18 +2652,19 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2523
2652
  // duplicate agent onto contested work, and the lease is live the moment the
2524
2653
  // agent starts (its post-tool writes then renew it — and seeing its own held
2525
2654
  // claim, it skips the redundant claim-nudge). Remote node-mode only; --no-claim
2526
- // opts out (dispatch with no lease, the prior behavior). Now SESSION-BOUND
2527
- // (dec-spor-session-identity-active-record): dispatch forces the session id, so
2528
- // the lease binds it and the bg agent's same-session heartbeat renews it.
2655
+ // opts out (dispatch with no lease, the prior behavior). PERSON-SCOPED here
2656
+ // (session omitted, dec-spor-dispatch-bg-session-late-bind): the real session
2657
+ // isn't known until after launch, so we bind it to the lease via renewDispatch
2658
+ // below; until then any of this person's sessions may renew it.
2529
2659
  if (nodeId && !backfill && !noClaim && cfg.mode() === "remote") {
2530
- const c = await claimDispatch(cfg, nodeId, session);
2660
+ const c = await claimDispatch(cfg, nodeId);
2531
2661
  if (c.conflict) {
2532
2662
  err(`${nodeId} is already claimed — ${c.message}`);
2533
2663
  err(` not dispatching a duplicate. Re-run with --no-claim to dispatch anyway (no lease),`);
2534
2664
  err(` or pick another task with 'spor next'.`);
2535
2665
  return 1;
2536
2666
  }
2537
- if (c.ok) out(`claimed ${nodeId} (session-bound lease established; the agent's writes will renew it)`);
2667
+ if (c.ok) out(`claimed ${nodeId} (lease established; the agent's writes will renew it)`);
2538
2668
  else err(`warning: could not establish a lease on ${nodeId}: ${c.error} — dispatching without a claim`);
2539
2669
  }
2540
2670
  claudeArgs.push(prompt);
@@ -2543,6 +2673,34 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
2543
2673
  err(`could not launch ${claudeBin}: ${r.error.message}`);
2544
2674
  return 1;
2545
2675
  }
2676
+
2677
+ // Late session binding (dec-spor-dispatch-bg-session-late-bind). `claude --bg`
2678
+ // has now self-allocated its run session and registered the agent; read the
2679
+ // REAL session from `claude agents --json` and bind it: (a) rebind the agent
2680
+ // token's session so every subsequent agent write stamps the real run, and
2681
+ // (b) renew the lease to it so lease and token agree (instead of waiting for
2682
+ // the agent's first heartbeat to self-heal). Best-effort throughout — a capture
2683
+ // miss or any bind failure leaves the token session-null (writes carry no
2684
+ // session: honest, never a phantom) and the lease self-healing via heartbeat.
2685
+ // Remote only, and only when there's something to bind (an agent token and/or a
2686
+ // claimed node).
2687
+ const wantBind = cfg.mode() === "remote" && (agentToken || (nodeId && !backfill && !noClaim));
2688
+ if (wantBind) {
2689
+ const realSession = await captureDispatchSession(name, res.dir, pinnedSession);
2690
+ if (realSession) {
2691
+ if (agentToken) {
2692
+ const b = await bindAgentSession(cfg, agentToken, realSession);
2693
+ if (b.ok) out(`session: ${realSession} (bound — the agent's writes trace to this run)`);
2694
+ else if (b.conflict) err(`note: the agent token is already bound to another session — leaving it.`);
2695
+ // absent/transport error: token stays session-deferred (no phantom) — silent, fail-open.
2696
+ } else {
2697
+ out(`session: ${realSession}`);
2698
+ }
2699
+ if (nodeId && !backfill && !noClaim) await renewDispatch(cfg, nodeId, realSession);
2700
+ } else if (agentToken) {
2701
+ err(`note: could not read the run session from 'claude agents' — writes will carry no session stamp (the lease still self-heals).`);
2702
+ }
2703
+ }
2546
2704
  return r.status == null ? 1 : r.status;
2547
2705
  }
2548
2706
 
package/lib/graph.js CHANGED
@@ -42,6 +42,15 @@ function loadGraph(nodesDir) {
42
42
  return kernel.buildGraph(shell.readGraphFiles(dir), { nodesDir: dir, seedSchemas: loadSeedSchemas() });
43
43
  }
44
44
 
45
+ // Incremental single-node cache update (SERVER.md §4.1, task-cc-spor-tier-2-scale):
46
+ // mutate a resident graph in place for one created/updated node instead of the
47
+ // O(corpus) loadGraph rebuild. Returns { id, mode } or { id, reloadRequired }.
48
+ // The kernel owns the tf-idf/adjacency representation, so the incremental twin
49
+ // of buildGraph lives beside it; the server reaches it through this seam.
50
+ function applyNode(graph, text, file) {
51
+ return kernel.applyNode(graph, text, file);
52
+ }
53
+
45
54
  function compile(graph, opts = {}) {
46
55
  return kernel.compile(graph, { ...opts, seedSchemas: opts.seedSchemas ?? loadSeedSchemas() });
47
56
  }
@@ -80,6 +89,7 @@ const KNOWN_EDGES = seedRegistry().knownEdgeTypes();
80
89
 
81
90
  module.exports = {
82
91
  loadGraph,
92
+ applyNode,
83
93
  compile,
84
94
  validateNode: kernel.validateNode,
85
95
  validateGraph,
@@ -108,6 +118,9 @@ module.exports = {
108
118
  // decision queue (QUEUE.md §4/§5): re-exported so the server keeps one door.
109
119
  // (Safe non-cycle: queue.js only requires graph.js inside its CLI block.)
110
120
  rankQueue: require("./queue.js").rankQueue,
121
+ // warm the queue's memoized HEAD-pure indexes on a resident graph
122
+ // (task-cc-rankqueue-memoization); the server calls it from store.reload().
123
+ warmQueueIndex: require("./queue.js").warmQueueIndex,
111
124
  EDGE_WEIGHTS,
112
125
  KNOWN_TYPES,
113
126
  KNOWN_EDGES,
@@ -152,10 +152,23 @@ function buildGraph(files, { nodesDir = "", seedSchemas = [] } = {}) {
152
152
 
153
153
  const adj = {};
154
154
  const supersededBy = {};
155
+ // danglingTo (task-cc-spor-tier-2-scale, §4.1 incremental cache): edges whose
156
+ // SOURCE is traversable but whose TARGET does not exist yet — the GRAPH.md
157
+ // "worth creating" markers. buildGraph skips them (no adjacency), but the
158
+ // incremental updater (applyNode) needs them: when the target is later
159
+ // created, those edges must light up, exactly as a from-scratch rebuild would
160
+ // make them. Keyed by the missing target id. A non-existent-but-non-
161
+ // traversable target is NOT dangling (it exists; the edge is permanently
162
+ // inactive), so only the `!nodes[e.to]` branch records here.
163
+ const danglingTo = {};
155
164
  for (const n of Object.values(nodes)) {
156
165
  if (!traversable(n)) continue;
157
166
  for (const e of n.edges) {
158
- if (!nodes[e.to] || !traversable(nodes[e.to])) continue;
167
+ if (!nodes[e.to]) {
168
+ (danglingTo[e.to] ??= []).push({ from: n.id, type: e.type, weight: reg.edgeWeight(e.type) });
169
+ continue;
170
+ }
171
+ if (!traversable(nodes[e.to])) continue;
159
172
  const w = reg.edgeWeight(e.type);
160
173
  (adj[n.id] ??= []).push({ to: e.to, type: e.type, weight: w });
161
174
  (adj[e.to] ??= []).push({ to: n.id, type: `${e.type} (inbound)`, weight: w });
@@ -187,8 +200,12 @@ function buildGraph(files, { nodesDir = "", seedSchemas = [] } = {}) {
187
200
  postings[t] = { docs: new Int32Array(df[t]), w: new Float64Array(df[t]) };
188
201
  cursor[t] = 0;
189
202
  }
203
+ // docIndex (task-cc-spor-tier-2-scale, §4.1): id -> position in `docs`, so the
204
+ // incremental updater can find a node's doc slot in O(1) instead of scanning.
205
+ const docIndex = Object.create(null);
190
206
  for (let i = 0; i < docs.length; i++) {
191
207
  const d = docs[i];
208
+ docIndex[d.id] = i;
192
209
  const dv = vec(d.tf); // transient; folded into the posting lists below
193
210
  let nn = 0;
194
211
  for (const t of Object.keys(dv)) {
@@ -202,11 +219,224 @@ function buildGraph(files, { nodesDir = "", seedSchemas = [] } = {}) {
202
219
  delete d.tf; // dead after this; rankAgainst reads only postings + d.norm
203
220
  }
204
221
 
205
- return { nodes, adj, supersededBy, docs, df, N, postings, nodesDir, registry: reg,
222
+ return { nodes, adj, supersededBy, docs, df, N, postings, docIndex, danglingTo, nodesDir, registry: reg,
206
223
  projectAliases: projectAliasMap(nodes), archivedProjects: archivedProjectSet(nodes),
207
224
  groupingRepos: groupingRepoMap(nodes) };
208
225
  }
209
226
 
227
+ // ---------- incremental cache update (SERVER.md §4.1, task-cc-spor-tier-2-scale) ----------
228
+ //
229
+ // applyNode(graph, text, file) mutates a resident graph IN PLACE to reflect one
230
+ // created or updated node, WITHOUT the O(corpus) rebuild loadGraph/buildGraph
231
+ // does on every write. At 50k nodes a full reload is ~3-5s, dominated entirely
232
+ // by re-tokenizing and re-vectorizing every node's tf-idf; this touches only the
233
+ // changed node's terms, so a write is O(node size + its terms' posting lengths),
234
+ // i.e. milliseconds. The structural maps (adj / supersededBy / danglingTo /
235
+ // docIndex) are patched with exactly the rules buildGraph applies, so the result
236
+ // is structurally identical to a rebuild.
237
+ //
238
+ // FIDELITY AND DRIFT. A from-scratch buildGraph scores every doc against the
239
+ // FINAL df/N. applyNode (re)scores ONLY the changed doc against the current
240
+ // df/N and leaves the already-resident docs' tf-idf weights at their last-build
241
+ // df/N. The per-write idf drift for an unchanged doc is log((N±1)/df) vs
242
+ // log(N/df) — vanishing against a large corpus — and is fully resynced by any
243
+ // full reload (boot, SIGHUP, the §4.1 HEAD poll, or the server's periodic
244
+ // resync after SPOR_RELOAD_EVERY writes). The changed doc itself is always
245
+ // scored exactly as a rebuild would score it. Posting-list array ORDER differs
246
+ // from a rebuild (append order vs directory order), but rankAgainst accumulates
247
+ // each doc's score across its query terms independent of intra-posting order, so
248
+ // only equal-sim tie ordering can differ — never a relevance result. The load-
249
+ // test harness (task-cc-spor-tier-2-scale) verifies top-K parity at scale.
250
+ //
251
+ // SCHEMA WRITES change the registry (edge weights, traversability) for the WHOLE
252
+ // graph and so cannot be patched locally — applyNode returns
253
+ // { reloadRequired: true } and the caller falls back to reload(). DELETIONS are
254
+ // likewise out of scope: the server write path never deletes a node, and an
255
+ // external delete arrives through the HEAD-poll full reload.
256
+ //
257
+ // Returns { id, mode: "create" | "update" } on success, or
258
+ // { id, reloadRequired: true } when a full reload is required instead.
259
+ function applyNode(graph, text, file) {
260
+ const node = parseFrontmatter(text, file || "incoming.md");
261
+ const id = node.id;
262
+ const reg = graph.registry;
263
+ // A schema node alters the registry globally; rebuild rather than patch.
264
+ if (node.type === "schema") return { id, reloadRequired: true };
265
+
266
+ // Invalidate the queue's memoized HEAD-pure indexes (task-cc-rankqueue-
267
+ // memoization): this write may change a blocks/resolves/answers/finding edge,
268
+ // supersession, or status that resolutionMap/openFindingsMap/blockersIndex/
269
+ // blockingCounts depend on, so the resident cache must not survive the
270
+ // mutation. The next rankQueue rebuilds it lazily; a full reload starts from a
271
+ // fresh graph object that never had one. Symbol.for keys the same slot
272
+ // queue.js sets, with no require cycle back into the queue kernel.
273
+ delete graph[Symbol.for("spor.queue.derived-index")];
274
+
275
+ const old = graph.nodes[id] || null;
276
+ const trav = (n) => !!n && reg.isTraversable(n.type);
277
+
278
+ // ---- structural maps: adjacency, supersededBy, danglingTo ----
279
+ // Remove the OLD node's own forward edges (and their inbound mirrors / dangling
280
+ // registrations) before installing the new node, then add the new node's.
281
+ if (trav(old)) removeNodeStructure(graph, old, reg);
282
+ graph.nodes[id] = node;
283
+ if (trav(node)) {
284
+ addNodeStructure(graph, node, reg);
285
+ activateDangling(graph, id, reg); // edges that were dangling onto this id light up
286
+ } else {
287
+ // A non-traversable node now exists: edges pointing at it stay permanently
288
+ // inactive (buildGraph skips a non-traversable target), so they are no
289
+ // longer "worth creating" markers — drop the dangling registrations.
290
+ delete graph.danglingTo[id];
291
+ }
292
+
293
+ // ---- tf-idf index: df / N / docs / postings (traversable nodes only) ----
294
+ // Type is fixed by the id prefix, so traversability is invariant across an
295
+ // update: a node that has a doc keeps one, and a non-traversable node never
296
+ // gets one.
297
+ if (trav(node)) {
298
+ const newTf = tf(tokens(`${node.title ?? ""} ${node.summary ?? ""} ${node.body}`));
299
+ const idx = old ? graph.docIndex[id] : null;
300
+ if (old && idx != null) {
301
+ // UPDATE: reuse the doc slot. Remove the old term contributions, then add
302
+ // the new — net df change is +1/-1 only for terms that entered or left.
303
+ const oldTf = tf(tokens(`${old.title ?? ""} ${old.summary ?? ""} ${old.body}`));
304
+ for (const t of Object.keys(oldTf)) {
305
+ if (graph.df[t] != null && (graph.df[t] -= 1) <= 0) delete graph.df[t];
306
+ removePosting(graph.postings, t, idx);
307
+ }
308
+ for (const t of Object.keys(newTf)) graph.df[t] = (graph.df[t] ?? 0) + 1;
309
+ // N is unchanged on an update. Score with the now-current df/N, exactly as
310
+ // a rebuild would score this doc in the post-update corpus.
311
+ const vec = makeVec(graph.df, graph.N);
312
+ const dv = vec(newTf);
313
+ let nn = 0;
314
+ for (const t of Object.keys(dv)) { const x = dv[t]; nn += x * x; pushPosting(graph.postings, t, idx, x); }
315
+ graph.docs[idx].norm = Math.sqrt(nn);
316
+ } else {
317
+ // CREATE: append a new doc slot.
318
+ for (const t of Object.keys(newTf)) graph.df[t] = (graph.df[t] ?? 0) + 1;
319
+ graph.N += 1;
320
+ const i = graph.docs.length;
321
+ const vec = makeVec(graph.df, graph.N);
322
+ const dv = vec(newTf);
323
+ let nn = 0;
324
+ for (const t of Object.keys(dv)) { const x = dv[t]; nn += x * x; pushPosting(graph.postings, t, i, x); }
325
+ graph.docs.push({ id, norm: Math.sqrt(nn) });
326
+ graph.docIndex[id] = i;
327
+ }
328
+ }
329
+
330
+ // ---- project / grouping / archived maps ----
331
+ // Only repo and project nodes feed these three maps; recompute them (each an
332
+ // O(N) scan with negligible per-node work) only when such a node is touched.
333
+ if (node.type === "repo" || node.type === "project" ||
334
+ (old && (old.type === "repo" || old.type === "project"))) {
335
+ graph.projectAliases = projectAliasMap(graph.nodes);
336
+ graph.archivedProjects = archivedProjectSet(graph.nodes);
337
+ graph.groupingRepos = groupingRepoMap(graph.nodes);
338
+ }
339
+
340
+ return { id, mode: old ? "update" : "create" };
341
+ }
342
+
343
+ // Remove a (traversable) node's OWN forward edges from the adjacency, along with
344
+ // the inbound mirrors they placed on their targets, the supersession entries
345
+ // they set, and the dangling registrations they made onto not-yet-existent
346
+ // targets. Inbound entries OTHER nodes placed on this node are owned by those
347
+ // nodes and left untouched.
348
+ function removeNodeStructure(graph, n, reg) {
349
+ const id = n.id;
350
+ for (const e of n.edges) {
351
+ const t = graph.nodes[e.to];
352
+ if (!t) { dropDangling(graph, e.to, id, e.type); continue; }
353
+ if (!reg.isTraversable(t.type)) continue;
354
+ removeAdjEdge(graph.adj, id, e.to, e.type); // forward
355
+ removeAdjEdge(graph.adj, e.to, id, `${e.type} (inbound)`); // inbound mirror
356
+ if (e.type === "supersedes" && graph.supersededBy[e.to] === id) delete graph.supersededBy[e.to];
357
+ }
358
+ }
359
+
360
+ // Add a (traversable) node's forward edges to the adjacency (with inbound
361
+ // mirrors), set supersession, and register edges onto not-yet-existent targets
362
+ // as dangling — the exact rules buildGraph's adjacency loop applies.
363
+ function addNodeStructure(graph, n, reg) {
364
+ const id = n.id;
365
+ for (const e of n.edges) {
366
+ const t = graph.nodes[e.to];
367
+ const w = reg.edgeWeight(e.type);
368
+ if (!t) { (graph.danglingTo[e.to] ??= []).push({ from: id, type: e.type, weight: w }); continue; }
369
+ if (!reg.isTraversable(t.type)) continue;
370
+ (graph.adj[id] ??= []).push({ to: e.to, type: e.type, weight: w });
371
+ (graph.adj[e.to] ??= []).push({ to: id, type: `${e.type} (inbound)`, weight: w });
372
+ if (e.type === "supersedes") graph.supersededBy[e.to] = id;
373
+ }
374
+ }
375
+
376
+ // A node `id` was just created and is traversable: edges that pointed at it
377
+ // while it was missing now light up, both directions, exactly as a rebuild
378
+ // would build them once the target exists.
379
+ function activateDangling(graph, id, reg) {
380
+ const pend = graph.danglingTo[id];
381
+ if (!pend) return;
382
+ for (const d of pend) {
383
+ const from = graph.nodes[d.from];
384
+ if (!from || !reg.isTraversable(from.type)) continue; // source vanished/became inert
385
+ (graph.adj[d.from] ??= []).push({ to: id, type: d.type, weight: d.weight });
386
+ (graph.adj[id] ??= []).push({ to: d.from, type: `${d.type} (inbound)`, weight: d.weight });
387
+ if (d.type === "supersedes") graph.supersededBy[id] = d.from;
388
+ }
389
+ delete graph.danglingTo[id];
390
+ }
391
+
392
+ function dropDangling(graph, to, from, type) {
393
+ const list = graph.danglingTo[to];
394
+ if (!list) return;
395
+ const i = list.findIndex((d) => d.from === from && d.type === type);
396
+ if (i >= 0) list.splice(i, 1);
397
+ if (list.length === 0) delete graph.danglingTo[to];
398
+ }
399
+
400
+ // Remove ONE adjacency entry matching {to, type} from adj[from]. Weight is a
401
+ // pure function of type, so to+type identifies the entry; duplicate edges
402
+ // balance because the caller removes one per edge it adds.
403
+ function removeAdjEdge(adj, from, to, type) {
404
+ const list = adj[from];
405
+ if (!list) return;
406
+ const i = list.findIndex((x) => x.to === to && x.type === type);
407
+ if (i >= 0) list.splice(i, 1);
408
+ if (list.length === 0) delete adj[from];
409
+ }
410
+
411
+ // Append (docIndex, weight) to term t's posting list. The base lists are typed
412
+ // arrays sized exactly at build time (issue-cc-graph-vec-posting-lists); a list
413
+ // touched by an incremental write is promoted once to plain growable arrays
414
+ // (rankAgainst reads .length/index identically for both, and the Float64 values
415
+ // are unchanged, so scores stay bit-identical). The next full reload restores
416
+ // the compact typed-array form, bounding the memory give-back to terms touched
417
+ // since the last reload.
418
+ function pushPosting(postings, t, idx, w) {
419
+ let p = postings[t];
420
+ if (!p) { postings[t] = { docs: [idx], w: [w] }; return; }
421
+ if (ArrayBuffer.isView(p.docs)) { p.docs = Array.from(p.docs); p.w = Array.from(p.w); }
422
+ p.docs.push(idx); p.w.push(w);
423
+ }
424
+
425
+ // Remove every entry for `idx` from term t's posting list (an update may have
426
+ // the same doc index appear once). Promotes a typed-array list to plain arrays.
427
+ function removePosting(postings, t, idx) {
428
+ const p = postings[t];
429
+ if (!p) return;
430
+ const docsArr = p.docs, wArr = p.w;
431
+ const nd = [], nw = [];
432
+ for (let k = 0; k < docsArr.length; k++) {
433
+ if (docsArr[k] === idx) continue;
434
+ nd.push(docsArr[k]); nw.push(wArr[k]);
435
+ }
436
+ if (nd.length === 0) delete postings[t];
437
+ else { p.docs = nd; p.w = nw; }
438
+ }
439
+
210
440
  // ---------- project identity (task-cc-project-identity-nodes) ----------
211
441
 
212
442
  // Slug -> project-node-id map from resident `type: project` nodes. Every
@@ -1065,6 +1295,7 @@ module.exports = {
1065
1295
  buildRegistry,
1066
1296
  schemaActive,
1067
1297
  buildGraph,
1298
+ applyNode,
1068
1299
  resolveProject,
1069
1300
  groupingOf,
1070
1301
  scopeFor,
@@ -107,8 +107,15 @@ function isLive(node, supersededBy) {
107
107
  }
108
108
 
109
109
  // Distinct live nodes transitively reachable over OUTBOUND `blocks` edges
110
- // (GRAPH.md: `n blocks t` == t cannot proceed until n does).
111
- function blockingCount(graph, id) {
110
+ // (GRAPH.md: `n blocks t` == t cannot proceed until n does). The result is a
111
+ // pure function of the graph's HEAD (structure + status/supersession liveness),
112
+ // so an optional `memo` (Map id->count) caches it across rankQueue calls on a
113
+ // resident graph (task-cc-rankqueue-memoization) — this per-item traversal is
114
+ // the dominant queue-build cost at scale. The cache lives on the graph object
115
+ // (queueIndex below) and is invalidated by reload/applyNode, so a memoized hit
116
+ // is byte-identical to a fresh traversal (norm-cc-byte-identical-refactor).
117
+ function blockingCount(graph, id, memo) {
118
+ if (memo) { const c = memo.get(id); if (c !== undefined) return c; }
112
119
  const seen = new Set();
113
120
  const stack = [id];
114
121
  while (stack.length) {
@@ -123,6 +130,7 @@ function blockingCount(graph, id) {
123
130
  }
124
131
  let live = 0;
125
132
  for (const tid of seen) if (isLive(graph.nodes[tid], graph.supersededBy)) live++;
133
+ if (memo) memo.set(id, live);
126
134
  return live;
127
135
  }
128
136
 
@@ -324,21 +332,48 @@ function heatScore(graph, id, activity) {
324
332
  return h;
325
333
  }
326
334
 
327
- // Fraction of this node's anchors that are superseded or missing.
328
- function stalenessScore(graph, node) {
335
+ // Fraction of this node's anchors that are superseded or missing. Like
336
+ // blockingCount this is a pure function of the graph's HEAD (edge structure +
337
+ // supersession), independent of every per-call input, so an optional `memo`
338
+ // (Map id->fraction) caches it across rankQueue calls on a resident graph
339
+ // (task-cc-rankqueue-staleness-date-memo). The cache lives on the graph object
340
+ // (queueIndex) and dies with reload/applyNode, so a hit returns the exact
341
+ // double a fresh scan produced (norm-cc-byte-identical-refactor).
342
+ function stalenessScore(graph, node, memo) {
343
+ if (memo) { const c = memo.get(node.id); if (c !== undefined) return c; }
329
344
  const edges = node.edges ?? [];
330
- if (!edges.length) return 0;
331
- let stale = 0;
332
- for (const e of edges) {
333
- if (!graph.nodes[e.to] || graph.supersededBy[e.to]) stale++;
345
+ let v;
346
+ if (!edges.length) v = 0;
347
+ else {
348
+ let stale = 0;
349
+ for (const e of edges) {
350
+ if (!graph.nodes[e.to] || graph.supersededBy[e.to]) stale++;
351
+ }
352
+ v = stale / edges.length;
334
353
  }
335
- return stale / edges.length;
354
+ if (memo) memo.set(node.id, v);
355
+ return v;
336
356
  }
337
357
 
338
- function ageDays(node, now) {
339
- if (!node.date || !/^\d{4}-\d{2}-\d{2}/.test(node.date)) return null;
340
- const t = Date.parse(node.date);
341
- if (Number.isNaN(t)) return null;
358
+ // The node's `date` parsed to epoch ms, or null when absent/malformed/
359
+ // unparseable. HEAD-pure — only the `(now - t)` in ageDays is per-call — so the
360
+ // parse is cacheable by id (task-cc-rankqueue-staleness-date-memo). A cached
361
+ // `null` (no usable date) is distinguished from "uncached" by the `undefined`
362
+ // check, so a dateless node is parsed at most once.
363
+ function nodeDateMs(node, memo) {
364
+ if (memo) { const c = memo.get(node.id); if (c !== undefined) return c; }
365
+ let t = null;
366
+ if (node.date && /^\d{4}-\d{2}-\d{2}/.test(node.date)) {
367
+ const p = Date.parse(node.date);
368
+ if (!Number.isNaN(p)) t = p;
369
+ }
370
+ if (memo) memo.set(node.id, t);
371
+ return t;
372
+ }
373
+
374
+ function ageDays(node, now, memo) {
375
+ const t = nodeDateMs(node, memo);
376
+ if (t == null) return null;
342
377
  return Math.max(0, Math.floor((now - t) / 86_400_000));
343
378
  }
344
379
 
@@ -421,6 +456,71 @@ function attachedSignals(reg, node, baseSignals, neighbors, sandboxFor) {
421
456
  }
422
457
  }
423
458
 
459
+ // HEAD-pure derived indexes the queue needs, memoized on the resident graph
460
+ // (task-cc-rankqueue-memoization). rankQueue rebuilt resolutionMap,
461
+ // openFindingsMap, blockersIndex, and a per-item transitive blockingCount on
462
+ // EVERY call — ~185ms at 50k nodes, dominated by the per-item work — even
463
+ // though every one of them is a pure function of the graph's HEAD, independent
464
+ // of the per-call inputs (now/leases/activity/viewer/project/filters). The
465
+ // server keeps the graph resident and only reloads when HEAD changes
466
+ // (dec-cc-defer-rust-rankqueue-kernel: the resident-graph principle, no wasm
467
+ // port), so these compute once and are reused across reads. Cache lifecycle is
468
+ // by graph identity, with no scheduler:
469
+ // - a fresh buildGraph()/loadGraph() result carries no cache, so a full
470
+ // reload self-invalidates (the new graph object simply has none);
471
+ // - applyNode() — the in-place incremental write path — deletes the cache
472
+ // (kernel/graph.js) since a mutation may change any blocks/resolves/
473
+ // answers/finding edge, supersession, or status the maps depend on; the
474
+ // next rankQueue rebuilds lazily.
475
+ // Keyed by a registry-global Symbol so it is invisible to Object.keys / JSON /
476
+ // spread (nothing enumerates the graph object) and graph.js can clear it
477
+ // without a require cycle back into queue.js. Outputs are byte-identical: the
478
+ // same pure functions over the same unchanged graph (norm-cc-byte-identical-
479
+ // refactor; the conformance queue goldens stand).
480
+ const QUEUE_INDEX = Symbol.for("spor.queue.derived-index");
481
+
482
+ function queueIndex(graph) {
483
+ let idx = graph[QUEUE_INDEX];
484
+ if (!idx) {
485
+ idx = graph[QUEUE_INDEX] = {
486
+ resolvedBy: resolution.resolutionMap(graph),
487
+ findingsFor: resolution.openFindingsMap(graph),
488
+ blockersOf: blockersIndex(graph),
489
+ blockingCounts: new Map(), // id -> transitive live-blocking count, lazily filled
490
+ stalenessScores: new Map(), // id -> edge-staleness fraction (HEAD-pure), lazily filled
491
+ dateMs: new Map(), // id -> parsed node.date ms or null (HEAD-pure), lazily filled
492
+ };
493
+ }
494
+ return idx;
495
+ }
496
+
497
+ // Eagerly populate the memoized index for a RESIDENT graph so the FIRST queue
498
+ // read after a HEAD change pays no index-build cost on the request path — the
499
+ // "compute at load/build time" half of task-cc-rankqueue-memoization. The
500
+ // server calls this from store.reload() (already an O(corpus) rebuild, so the
501
+ // warm is negligible against it and happens only when HEAD changes); the
502
+ // incremental write path deliberately does NOT warm — it invalidates and lets
503
+ // the next read rebuild lazily, keeping put_node latency flat
504
+ // (task-cc-spor-tier-2-scale). Fills blockingCounts plus the per-node HEAD-pure
505
+ // scoring pieces — edge-staleness and the parsed node.date
506
+ // (task-cc-rankqueue-staleness-date-memo) — for every queueable-live, unresolved
507
+ // node, the superset any project/assignee/type-filtered rankQueue draws from, so
508
+ // every later read is a pure cache hit. Returns the index.
509
+ function warmQueueIndex(graph) {
510
+ const idx = queueIndex(graph);
511
+ const reg = graph.registry;
512
+ if (!reg || typeof reg.isQueueable !== "function") return idx;
513
+ for (const node of Object.values(graph.nodes)) {
514
+ if (!reg.isQueueable(node.type)) continue;
515
+ if (!isLive(node, graph.supersededBy)) continue;
516
+ if (idx.resolvedBy[node.id]) continue; // retired by a live resolves/answers edge
517
+ blockingCount(graph, node.id, idx.blockingCounts);
518
+ stalenessScore(graph, node, idx.stalenessScores);
519
+ nodeDateMs(node, idx.dateMs);
520
+ }
521
+ return idx;
522
+ }
523
+
424
524
  // rankQueue(graph, { project?, assignee?, includeTypes?, excludeTypes?, activity?, front?, leases?, now?, limit?, viewer?, sandboxFor? })
425
525
  // -> { items, count, muted?, dormant?, archived?, leased?, reserved? }
426
526
  // `includeTypes`/`excludeTypes` (task-cc-queue-filtering-enhancements) are
@@ -548,11 +648,23 @@ function rankQueue(graph, opts = {}) {
548
648
  // questions already do — zero mutation, the status flip stays a human act
549
649
  // (the gardener's resolved-open finding nags for it). Open findings about
550
650
  // an item ride along so the consumer sees what the gardener already knows.
551
- const resolvedBy = resolution.resolutionMap(graph);
552
- const findingsFor = resolution.openFindingsMap(graph);
553
- const blockersOf = blockersIndex(graph);
651
+ // HEAD-pure derived indexes, memoized on the resident graph and reused across
652
+ // reads (task-cc-rankqueue-memoization); recomputed lazily on a fresh/mutated
653
+ // graph. Independent of the per-call inputs above, so caching is sound.
654
+ const idx = queueIndex(graph);
655
+ const { resolvedBy, findingsFor, blockersOf } = idx;
554
656
 
555
657
  const items = [];
658
+ // Deferred per-item RENDER (issue-cc-queue-percall-blend-latency-floor,
659
+ // lever 1): the presentation labels (suggest, the why-line and its blocker/
660
+ // cross-project descriptors, the wake/lease/findings annotations) are built
661
+ // AFTER sort+slice for just the `limit` items actually returned, not for every
662
+ // live node. The only render input not cheap to recompute for the survivors is
663
+ // each node's live-blocker set (already allocated for `blocked_by` and needed
664
+ // for the cap pass over ALL items), so it rides in this id->blockers map; the
665
+ // render pass recomputes node/lease/wake from the id. An entry's presence also
666
+ // marks a normal (vs schema-approval) item for the render pass.
667
+ const blockersById = new Map();
556
668
  for (const node of Object.values(graph.nodes)) {
557
669
  // Schema-change proposals are queue items (§2.4/§5): a proposed schema
558
670
  // waits for a human to review the payload + attached code and flip its
@@ -563,7 +675,7 @@ function rankQueue(graph, opts = {}) {
563
675
  if (!inTypes("schema")) continue; // hard type scope, uncounted (like project)
564
676
  if (isArchived(node)) { archivedCount++; continue; }
565
677
  if (isMuted(node)) { muted++; continue; }
566
- const age = ageDays(node, now);
678
+ const age = ageDays(node, now, idx.dateMs);
567
679
  items.push({
568
680
  id: node.id,
569
681
  title: node.title ?? null,
@@ -605,28 +717,17 @@ function rankQueue(graph, opts = {}) {
605
717
 
606
718
  const blockers = liveBlockers(graph, node.id, blockersOf, resolvedBy);
607
719
  const signals = {
608
- blocking: blockingCount(graph, node.id),
720
+ blocking: blockingCount(graph, node.id, idx.blockingCounts),
609
721
  blocked_by: blockers.length,
610
722
  front: front?.[node.id] ?? 0, // this node only — no neighborhood spread
611
723
  heat: heatScore(graph, node.id, activity),
612
- staleness: Number(stalenessScore(graph, node).toFixed(2)),
613
- age_days: ageDays(node, now),
724
+ staleness: Number(stalenessScore(graph, node, idx.stalenessScores).toFixed(2)),
725
+ age_days: ageDays(node, now, idx.dateMs),
614
726
  };
615
727
  // needed_by_days rides along ONLY when the node carries a deadline, so the
616
728
  // signals shape (and every existing why-line/golden) is unchanged without it.
617
729
  const nbDays = neededByDays(neededByTime(node), now);
618
730
  if (nbDays != null) signals.needed_by_days = nbDays;
619
- // Cross-project provenance descriptors for the why-line: blockers carry
620
- // their project (annotated only when it differs), and the direct blocks
621
- // targets in another project name who this dependency serves.
622
- const ownProject = node.project ?? null;
623
- const blockerDescs = blockers.map((id) => {
624
- const p = graph.nodes[id]?.project ?? null;
625
- return { id, project: p, cross: p !== ownProject };
626
- });
627
- const crossBlocking = directLiveBlocks(graph, node, resolvedBy)
628
- .filter((t) => (t.project ?? null) !== ownProject)
629
- .map((t) => ({ id: t.id, project: t.project ?? null }));
630
731
  const bump = PRIORITY_BUMP[node.priority] ?? 0;
631
732
  let score = bump + 3 * signals.blocking - 3 * signals.blocked_by +
632
733
  Math.min(Math.log2(1 + signals.front), 5) +
@@ -642,6 +743,10 @@ function rankQueue(graph, opts = {}) {
642
743
  }
643
744
  if (attached?.error) signals.queue_signals_error = attached.error;
644
745
 
746
+ // Ranking fields only; the presentation labels (suggest/why/blocked_by/
747
+ // lease/findings) are deferred to the post-slice render pass below
748
+ // (issue-cc-queue-percall-blend-latency-floor). Keys are inserted in the
749
+ // same order render later appends to, so the serialized item is unchanged.
645
750
  const item = {
646
751
  id: node.id,
647
752
  title: node.title ?? null,
@@ -651,33 +756,8 @@ function rankQueue(graph, opts = {}) {
651
756
  priority: node.priority ?? null,
652
757
  score: Number(score.toFixed(2)),
653
758
  signals,
654
- // staleness wins (closing retires the item, blocked or not), then
655
- // blocked — an item gated by live work isn't actionable either way.
656
- suggest: signals.staleness >= STALE_SUGGEST_THRESHOLD ? "close"
657
- : blockers.length ? "blocked" : "do",
658
- why: whyLine(signals, node, blockerDescs, crossBlocking, frontDays),
659
759
  };
660
- if (wake != null) item.why += `; woke ${node.wake} (was dormant)`;
661
- if (blockers.length) item.blocked_by = blockers;
662
- // A surviving lease rides along as `lease_state` so the consumer can render
663
- // it (dec-cc-task-claim-lease's "demoted-not-erased" / the steward view's
664
- // "in progress by X"). Only two ways an item gets here with a lease: the
665
- // viewer holds it (their own queue), or this is the lease-exempt steward
666
- // read. Tier-1 live -> "in progress", Tier-2 reservation -> "reserved"; the
667
- // owner sees "yours", a steward sees "by <holder>". Counted demotions
668
- // (held by another, hidden) never reach here — they `continue`d above.
669
- if (lease) {
670
- const mine = lease.by === viewerHolder;
671
- item.lease_state = lease.reserved ? "reserved" : "in_progress";
672
- item.lease_by = lease.by;
673
- const label = lease.reserved ? "reserved" : "in progress";
674
- item.why += mine ? `; ${label} (your claim)` : `; ${label} by ${lease.by}`;
675
- }
676
- const flagged = findingsFor[node.id];
677
- if (flagged?.length) {
678
- item.findings = flagged.map((f) => f.id);
679
- item.why += `; ${flagged.length} open gardener finding${flagged.length === 1 ? "" : "s"}: ${item.findings.join(", ")}`;
680
- }
760
+ blockersById.set(node.id, blockers);
681
761
  items.push(item);
682
762
  }
683
763
 
@@ -690,7 +770,9 @@ function rankQueue(graph, opts = {}) {
690
770
  for (let pass = 0; pass < 10; pass++) {
691
771
  let changed = false;
692
772
  for (const it of items) {
693
- for (const bid of it.blocked_by ?? []) {
773
+ // blockers ride in blockersById until render re-adds `blocked_by` post-slice
774
+ // (deferred render); schema-approval items aren't in the map -> no blockers.
775
+ for (const bid of blockersById.get(it.id) ?? []) {
694
776
  const blocker = byId.get(bid);
695
777
  if (blocker && it.score >= blocker.score) {
696
778
  it.score = Number((blocker.score - 0.01).toFixed(2));
@@ -734,7 +816,65 @@ function rankQueue(graph, opts = {}) {
734
816
  (b.signals.age_days ?? 0) - (a.signals.age_days ?? 0) ||
735
817
  (a.id < b.id ? -1 : 1));
736
818
 
737
- const r = { items: items.slice(0, Math.max(0, limit)), count: items.length };
819
+ // Render pass (issue-cc-queue-percall-blend-latency-floor, lever 1): build the
820
+ // presentation labels for ONLY the items the slice returns. The same labels the
821
+ // per-node loop used to build for every live node — suggest, the why-line with
822
+ // its blocker/cross-project descriptors, the wake/lease/findings annotations,
823
+ // and the re-added `blocked_by` — now cost O(limit), not O(live queue), cutting
824
+ // the per-call string/array allocations that drove the p95/p99 GC tail. Each
825
+ // appended key lands in its original position, so a returned item is
826
+ // byte-identical (norm-cc-byte-identical-refactor). Schema-approval items carry
827
+ // a constant why and aren't in blockersById, so they're left as rendered inline.
828
+ const top = items.slice(0, Math.max(0, limit));
829
+ for (const item of top) {
830
+ const blockers = blockersById.get(item.id);
831
+ if (blockers === undefined) continue; // schema-approval item: rendered inline
832
+ const node = graph.nodes[item.id];
833
+ // lease/wake are cheap to recompute for the survivors from the same `now`,
834
+ // so they aren't retained per node; identical to the loop's computation.
835
+ const lease = leaseState(leases, node.id, now);
836
+ const wake = wakeTime(node);
837
+ const signals = item.signals;
838
+ // staleness wins (closing retires the item, blocked or not), then blocked —
839
+ // an item gated by live work isn't actionable either way.
840
+ item.suggest = signals.staleness >= STALE_SUGGEST_THRESHOLD ? "close"
841
+ : blockers.length ? "blocked" : "do";
842
+ // Cross-project provenance descriptors for the why-line: blockers carry their
843
+ // project (annotated only when it differs), and the direct blocks targets in
844
+ // another project name who this dependency serves.
845
+ const ownProject = node.project ?? null;
846
+ const blockerDescs = blockers.map((id) => {
847
+ const p = graph.nodes[id]?.project ?? null;
848
+ return { id, project: p, cross: p !== ownProject };
849
+ });
850
+ const crossBlocking = directLiveBlocks(graph, node, resolvedBy)
851
+ .filter((t) => (t.project ?? null) !== ownProject)
852
+ .map((t) => ({ id: t.id, project: t.project ?? null }));
853
+ item.why = whyLine(signals, node, blockerDescs, crossBlocking, frontDays);
854
+ if (wake != null) item.why += `; woke ${node.wake} (was dormant)`;
855
+ if (blockers.length) item.blocked_by = blockers;
856
+ // A surviving lease rides along as `lease_state` so the consumer can render
857
+ // it (dec-cc-task-claim-lease's "demoted-not-erased" / the steward view's
858
+ // "in progress by X"). Only two ways an item gets here with a lease: the
859
+ // viewer holds it (their own queue), or this is the lease-exempt steward
860
+ // read. Tier-1 live -> "in progress", Tier-2 reservation -> "reserved"; the
861
+ // owner sees "yours", a steward sees "by <holder>". Counted demotions
862
+ // (held by another, hidden) never reach here — they `continue`d above.
863
+ if (lease) {
864
+ const mine = lease.by === viewerHolder;
865
+ item.lease_state = lease.reserved ? "reserved" : "in_progress";
866
+ item.lease_by = lease.by;
867
+ const label = lease.reserved ? "reserved" : "in progress";
868
+ item.why += mine ? `; ${label} (your claim)` : `; ${label} by ${lease.by}`;
869
+ }
870
+ const flagged = findingsFor[node.id];
871
+ if (flagged?.length) {
872
+ item.findings = flagged.map((f) => f.id);
873
+ item.why += `; ${flagged.length} open gardener finding${flagged.length === 1 ? "" : "s"}: ${item.findings.join(", ")}`;
874
+ }
875
+ }
876
+
877
+ const r = { items: top, count: items.length };
738
878
  if (muted > 0) r.muted = muted;
739
879
  if (dormant > 0) r.dormant = dormant;
740
880
  if (archivedCount > 0) r.archived = archivedCount;
@@ -746,4 +886,4 @@ function rankQueue(graph, opts = {}) {
746
886
  return r;
747
887
  }
748
888
 
749
- module.exports = { rankQueue, PRIORITY_BUMP, DEFAULT_LIMIT };
889
+ module.exports = { rankQueue, warmQueueIndex, PRIORITY_BUMP, DEFAULT_LIMIT };
package/lib/queue.js CHANGED
@@ -100,7 +100,7 @@ function gitFront(repoDir, nodesName, days) {
100
100
  return out;
101
101
  }
102
102
 
103
- module.exports = { rankQueue, gitFront, gitIdentityEmail, viewerFor, PRIORITY_BUMP: kernel.PRIORITY_BUMP, DEFAULT_LIMIT: kernel.DEFAULT_LIMIT };
103
+ module.exports = { rankQueue, warmQueueIndex: kernel.warmQueueIndex, gitFront, gitIdentityEmail, viewerFor, PRIORITY_BUMP: kernel.PRIORITY_BUMP, DEFAULT_LIMIT: kernel.DEFAULT_LIMIT };
104
104
 
105
105
  // ---------- CLI (local mode / debugging; new entry point, existing CLIs untouched) ----------
106
106
 
package/lib/remote.js CHANGED
@@ -27,8 +27,11 @@ function isRemote(cfg) {
27
27
 
28
28
  // One request. Returns { ok, status, json, text } on an HTTP response (any
29
29
  // status), or { ok:false, transport:true, error } when the request never
30
- // completed. Never throws.
31
- async function request(cfg, method, apiPath, { body, timeoutMs = 6000 } = {}) {
30
+ // completed. Never throws. `opts.token` overrides the cfg-resolved bearer for
31
+ // this one call `spor dispatch` uses it to authenticate as the freshly-minted
32
+ // agent token (not the person token) when late-binding the run session
33
+ // (issue-spor-dispatch-bg-session-late-bind).
34
+ async function request(cfg, method, apiPath, { body, timeoutMs = 6000, token: tokenOverride } = {}) {
32
35
  const url = base(cfg) + apiPath;
33
36
  const ctrl = new AbortController();
34
37
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
@@ -36,7 +39,7 @@ async function request(cfg, method, apiPath, { body, timeoutMs = 6000 } = {}) {
36
39
  const res = await fetch(url, {
37
40
  method,
38
41
  headers: {
39
- Authorization: `Bearer ${token(cfg)}`,
42
+ Authorization: `Bearer ${tokenOverride || token(cfg)}`,
40
43
  ...(body != null ? { "Content-Type": "application/json" } : {}),
41
44
  },
42
45
  body: body != null ? JSON.stringify(body) : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
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",