@sporhq/spor 0.29.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "name": "spor",
3
3
  "displayName": "Spor Context Compiler",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
- "version": "0.29.0",
5
+ "version": "0.29.1",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spor",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
5
  "author": {
6
6
  "name": "Spor",
package/API.md CHANGED
@@ -601,7 +601,7 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
601
601
  | `DELETE /v1/me/tokens/{hash-prefix}` | `spor token revoke` | revoke one of the caller's OWN PATs by hash prefix → `{revoked, hash_prefix, oauth_grants_revoked}`; a prefix that isn't one of the caller's is `404` (never another person's token). `403` if unbound. Shares the admin revoke's OAuth-grant cascade-completeness invariant (issue-cc-pat-revoke-cascades-all-oauth-grants) |
602
602
  | `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 |
603
603
  | `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. **Digest rerank (task-spor-compile-jev-rerank-stage, per-org opt-in, default OFF, server half task-split-spor-server-eb6de3fca31e):** when the org has opted in, the server scores the candidate pool (cosine top-30 ∪ top structural) with `jev.rerank` and hands the verdicts to `compile()` as `opts.rerankScores` — a `{ [id]: { score, noul } }` map, `score` an ordinal 0-3 (irrelevant / background / directly relevant / required) and `noul` a 0-1 probability that that candidate answers or resolves the query. `compile()` stays pure — it never calls Jev itself, it only reorders the section it already builds under `DIGEST_CAP`: pinned → scored candidates (`noul` desc, `score` secondary, cosine-sim tiebreak) → remaining structural (unscored, original score-desc order) → remaining content (unscored, original sim-desc order) — and reports it back as `meta.rerank: {applied: true, candidates: <n>}` (absent when rerank did not run) for the response's own additive `rerank` summary field to echo. Fail-open: an org that hasn't opted in, local mode (no server to call Jev with), or a call that omits/empties `opts.rerankScores` all render byte-identical to before (norm-cc-byte-identical-refactor). **Optional `intent`** (task-spor-digest-intent-jev-gate / task-split-spor-server-bca885114354): when the org has Jev enabled and the request carries a `query`, the server also returns `intent: {warranted: bool, needs_history: 0–1, digest_helps: 0–1, source: "jev"}` — the digest-intent verdict computed server-side (`warranted` = the deterministic prompt heuristics, then max(noul) ≥ 0.5). It is **absent** whenever it was not computed (Jev disabled/shed/timed out/failed, a `root` walk, an older server): fail-open, never `warranted: false` by default. The prompt-context hook suppresses THIS prompt's digest only on an explicit boolean `warranted: false`, and only when its digest intent gate is on (`digest.async`, a tri-state: explicit `true`/`false`, unset = the client default); an absent or non-boolean field is ignored, and so is one on a `found: false` response (its `digest_helps` judged an empty team digest, not the client's personal-graph merge) |
604
- | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; hidden and absent ids both return `404 not_found`. 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 **visible** 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 visible gardener findings about the node ride along as `open_findings` (`bin/spor.js`'s `dispatchDeclineFindingCheck` reads it to refuse re-dispatching a node a prior run DECLINED — task-spor-decline-finding-gates-redispatch — filtering for a live `find-declined-*` entry), and a node marked stale by a visible inbound supersedes edge as `superseded_by`. Returned `raw`/`frontmatter.edges` redact invisible edge targets. All enrichment is additive top-level keys; ignore unknown ones. Reserved: a top-level `inert` boolean — this node's status evaluated against the FULL type-aware queue-liveness-dead partition, including graph-resident schema overrides a graph-less client can't see (issue-spor-type-blind-terminal-status-fallbacks). The graph-less client callers that need it (`bin/spor.js` `dispatchResolutionReason`, `distill.js` `sessionEndLease`) already read it opportunistically when present and fall back to an offline seed-registry check otherwise, but no server response emits the key yet — implementing it server-side is separate, tracked work |
604
+ | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; hidden and absent ids both return `404 not_found`. 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 **visible** 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 visible gardener findings about the node ride along as `open_findings` (`bin/spor.js`'s `dispatchDeclineFindingCheck` reads it to refuse re-dispatching a node a prior run DECLINED — task-spor-decline-finding-gates-redispatch — filtering for a live `find-declined-*` entry), and a node marked stale by a visible inbound supersedes edge as `superseded_by`. Returned `raw`/`frontmatter.edges` redact invisible edge targets. `?inbound=1` adds `inbound_edges`: every inbound edge as `{from, type}` in the order a `GET /v1/export` sweep yields (sources sorted by id, each source's edges in file order) — what `spor get --json` uses instead of downloading the export per read (issue-spor-remote-get-json-full-export-per-call); omitted by default. All enrichment is additive top-level keys; ignore unknown ones. Reserved: a top-level `inert` boolean — this node's status evaluated against the FULL type-aware queue-liveness-dead partition, including graph-resident schema overrides a graph-less client can't see (issue-spor-type-blind-terminal-status-fallbacks). The graph-less client callers that need it (`bin/spor.js` `dispatchResolutionReason`, `distill.js` `sessionEndLease`) already read it opportunistically when present and fall back to an offline seed-registry check otherwise, but no server response emits the key yet — implementing it server-side is separate, tracked work |
605
605
  | `POST /v1/nodes/batch` `{ids?\|cursor?}` | `get_nodes` | bounded plural `get_node`: start with 1–100 ids, deduplicated by first occurrence; hidden/absent ids appear only in `missing_ids`. Returns `{nodes, returned_ids, missing_ids, truncated, next_cursor}` with the same per-node read enrichment as the single route, capped at 48 KiB without splitting entries. Resume a truncated logical request by sending only its opaque `cursor`; the final page carries `next_cursor: null` |
606
606
  | `GET /v1/nodes/{id}/history?limit=N` | `spor history <id>`, the `node_history` MCP tool | per-node commit lineage — a `git log` projection over `nodes/{id}.md` → `{id, head, count, history: [{sha, short, actor, actor_name, actor_email, date, message, internal, person}]}`, newest first. Each revision is labeled `internal:true` for a server-internal write (boot reconcile / migration, `server@spor.invalid`) vs. a real actor, and mapped to its `person` node by author email. Deliberately NOT `git log --follow` (node files share heavy frontmatter boilerplate, so similarity-based rename detection crosses node boundaries — dec-spor-node-history-git-log-projection). `limit` defaults to 50, max 200. The frontmatter `author` re-stamps to the LAST editor on every write, so this is the only durable record of the full chain of editors. A node with no commit history (unknown id) is `404`; a bad id is `422`. The `spor history <id>` CLI verb is the shell front-door (remote reads this; local mode runs the same projection over the graph home) |
607
607
  | `GET /v1/nodes/{id}/history/{sha}` | `spor history <id> <sha>`, `node_history` (sha mode) | one revision's detail, the expensive half gated behind an explicit per-sha fetch → the history record for that commit plus `{change, patch, content}`: the change type (`A`/`M`/`D`/`R`), the patch this commit introduced to the node file, and the full node content at that revision (`null` when the commit deleted it). The `sha` must be one from the node's own history — a sha that didn't touch the node, or an unresolvable sha, is `404`; a malformed sha is `422` |
@@ -888,9 +888,12 @@ rather than re-POSTed forever; `429` and `5xx` stay transient and are
888
888
  retried with backoff.
889
889
 
890
890
  `GET /v1/export` response headers: `x-substrate-head` carries the graph
891
- commit, `x-substrate-node-count` the entry count (plus
892
- `x-substrate-skipped` when any entry was omitted, and `x-substrate-auth-files`
893
- on an `?auth=1` export — the count of `auth/*.json` files bundled). A
891
+ commit, `x-substrate-node-count` the entry count (plus `x-substrate-auth-files`
892
+ on an `?auth=1` export — the count of `auth/*.json` files bundled). A path too
893
+ long for any ustar name/prefix split (a node id near the 200-byte limit) is
894
+ written under a POSIX pax extended header carrying its full `path`, so no entry
895
+ is omitted; the legacy `x-substrate-skipped` header is no longer emitted and a
896
+ client should treat its absence as zero. A
894
897
  `?history=1` bundle carries only `x-substrate-head` (a git bundle has no node
895
898
  count). These header names are a wire contract and were deliberately **not**
896
899
  renamed in the Spor rename — clients should keep reading the `x-substrate-*`
package/bin/spor.js CHANGED
@@ -1103,7 +1103,11 @@ async function cmdGet(cfg, { positionals, values }) {
1103
1103
  return 1;
1104
1104
  }
1105
1105
  if (cfg.mode() === "remote") {
1106
- const r = await remote.get(cfg, `/v1/nodes/${encodeURIComponent(id)}`, { timeoutMs: 6000 });
1106
+ // --json asks the server for the node's inbound edges in the same read
1107
+ // (?inbound=1, issue-spor-remote-get-json-full-export-per-call); an older
1108
+ // server ignores the param and the export sweep below fills them instead.
1109
+ const q = values.json ? "?inbound=1" : "";
1110
+ const r = await remote.get(cfg, `/v1/nodes/${encodeURIComponent(id)}${q}`, { timeoutMs: 6000 });
1107
1111
  if (r.transport) {
1108
1112
  err(`offline — could not reach server (${r.error})`);
1109
1113
  return 1;
@@ -1122,9 +1126,10 @@ async function cmdGet(cfg, { positionals, values }) {
1122
1126
  return 0;
1123
1127
  }
1124
1128
  // --json: parse the raw with the SAME lib parser as local (parity), take the
1125
- // server's git-blob-sha revision, and gather inbound edges from the team graph
1126
- // (the documented graph-wide sweep via GET /v1/export — there is no inbound
1127
- // endpoint, the same path `spor query --to` walks).
1129
+ // server's git-blob-sha revision, and take inbound edges from the server's
1130
+ // inbound_edges when it sent them. Only an older server without ?inbound=1
1131
+ // falls back to the graph-wide sweep via GET /v1/export (the path `spor
1132
+ // query --to` walks) — a full download per read, so never the first choice.
1128
1133
  const graphLib = require(path.join(ROOT, "lib", "graph.js"));
1129
1134
  const raw = r.json && r.json.raw;
1130
1135
  if (typeof raw !== "string") {
@@ -1132,13 +1137,17 @@ async function cmdGet(cfg, { positionals, values }) {
1132
1137
  return 1;
1133
1138
  }
1134
1139
  const node = graphLib.parseFrontmatter(raw, `${id}.md`);
1135
- const fetched = await fetchRemoteExportNodes(cfg, "get");
1136
- if (fetched.error) return 1; // already reported
1137
- let inbound;
1138
- try {
1139
- inbound = inboundEdges(graphLib.loadGraph(fetched.nodesDir), node.id);
1140
- } finally {
1141
- fetched.cleanup();
1140
+ let inbound = Array.isArray(r.json.inbound_edges)
1141
+ ? r.json.inbound_edges.map((e) => ({ from: e.from, type: e.type }))
1142
+ : null;
1143
+ if (!inbound) {
1144
+ const fetched = await fetchRemoteExportNodes(cfg, "get");
1145
+ if (fetched.error) return 1; // already reported
1146
+ try {
1147
+ inbound = inboundEdges(graphLib.loadGraph(fetched.nodesDir), node.id);
1148
+ } finally {
1149
+ fetched.cleanup();
1150
+ }
1142
1151
  }
1143
1152
  out(JSON.stringify(getNodeJson(node, inbound, r.json.revision), null, 2));
1144
1153
  return 0;
@@ -1010,6 +1010,18 @@ function compile(graph, opts = {}) {
1010
1010
  // along is a schema flag now (always_on: true; the seed sets it on norm).
1011
1011
  const reg = graph.registry ?? seedRegistry(opts.seedSchemas ?? []);
1012
1012
 
1013
+ // Supersession by STATUS as well as by edge (issue-spor-superseded-context-
1014
+ // injected-as-live): a node whose own status reads `superseded` but whose
1015
+ // replacement never earned a `supersedes` edge used to render with only a
1016
+ // `, superseded` parenthetical — which the prompt hook's compaction dropped —
1017
+ // so it reached ambient context reading as live guidance. Both forms now get
1018
+ // the same treatment: ⚠ block + summary-only in briefings, dropped from the
1019
+ // digest (unless a correction pinned it), never riding along as a norm.
1020
+ const isSuperseded = (n) => !!supersededBy[n.id] || String(n.status ?? "").toLowerCase() === "superseded";
1021
+ const supersededLabel = (n) => supersededBy[n.id]
1022
+ ? `SUPERSEDED by ${supersededBy[n.id]}`
1023
+ : "SUPERSEDED (status: superseded; no superseding node linked)";
1024
+
1013
1025
  // Project scoping (issue-cc-digest-unscoped-cross-project-ranking): under a
1014
1026
  // single-org-graph topology tf-idf ranks every prompt against ALL teams'
1015
1027
  // nodes equally, so shared vocabulary ("auth", "deploy", "migration")
@@ -1256,6 +1268,11 @@ function compile(graph, opts = {}) {
1256
1268
  };
1257
1269
  const normCandidates = Object.values(nodes).filter((n) =>
1258
1270
  reg.isAlwaysOn(n.type) && normRidesAlong(n) &&
1271
+ // A superseded or retired norm is no longer team policy; the ride-along
1272
+ // injects unasked into every session, so it must never carry one
1273
+ // (issue-spor-superseded-context-injected-as-live). It can still surface
1274
+ // through the relevance arms, where render() flags it.
1275
+ !isSuperseded(n) && !resolution.isTerminalStatus(n.status, n.type, graph) &&
1259
1276
  !structuralSet.has(n.id) && !contentSet.has(n.id) && !pinned.has(n.id) && !excluded.has(n.id));
1260
1277
  // When the candidate set exceeds the cap, KEEP the most topically relevant
1261
1278
  // (then by id, deterministic) but RENDER them in the original insertion
@@ -1292,13 +1309,13 @@ function compile(graph, opts = {}) {
1292
1309
  // whatever its status says, so a terminal status there is reported as HELD
1293
1310
  // rather than as done — the read-time twin of the queue's rule.
1294
1311
  function statusTag(n) {
1295
- if (supersededBy[n.id] || !resolution.isTerminalStatus(n.status, n.type, graph)) return "";
1312
+ if (isSuperseded(n) || !resolution.isTerminalStatus(n.status, n.type, graph)) return "";
1296
1313
  return resolution.executionHeld(n) ? `, ${n.status.toLowerCase()} — HELD by execution ${n.execution}, not retired` : `, ${n.status.toLowerCase()}`;
1297
1314
  }
1298
1315
  // Inline ⚠ for an edge-retired node whose status still reads live, mirroring
1299
1316
  // the get_node lead line. Superseded nodes are handled separately.
1300
1317
  function resolutionWarn(n) {
1301
- if (supersededBy[n.id] || resolution.isTerminalStatus(n.status, n.type, graph)) return "";
1318
+ if (isSuperseded(n) || resolution.isTerminalStatus(n.status, n.type, graph)) return "";
1302
1319
  const r = resMap[n.id];
1303
1320
  if (!r) return "";
1304
1321
  const when = r.date ? ` (${r.date})` : "";
@@ -1317,10 +1334,10 @@ function compile(graph, opts = {}) {
1317
1334
  // ---------- rendering ----------
1318
1335
  function render(n, full, provenance) {
1319
1336
  const head = `### ${n.id} — ${n.title} (${n.type}, ${n.date}${statusTag(n)}${crossTag(n)}${machineAuthorshipTag(n)})\n*selected via: ${provenance}*\n`;
1320
- const stale = supersededBy[n.id]
1321
- ? `\n> ⚠ SUPERSEDED by ${supersededBy[n.id]}. Do not follow; included only so you recognize stale references.\n`
1337
+ const stale = isSuperseded(n)
1338
+ ? `\n> ⚠ ${supersededLabel(n)}. Do not follow; included only so you recognize stale references.\n`
1322
1339
  : resolutionWarn(n) ? `\n>${resolutionWarn(n)}.\n` : "";
1323
- return (full && !supersededBy[n.id]) ? `${head}${stale}\n${n.body}\n` : `${head}${stale}\n${n.summary}\n`;
1340
+ return (full && !isSuperseded(n)) ? `${head}${stale}\n${n.body}\n` : `${head}${stale}\n${n.summary}\n`;
1324
1341
  }
1325
1342
 
1326
1343
  // Norm rendering (issue-cc-norm-always-on-injection): an always_on norm rides
@@ -1360,8 +1377,14 @@ function compile(graph, opts = {}) {
1360
1377
  const add = (n, tag) => {
1361
1378
  if (seen.has(n.id)) return;
1362
1379
  seen.add(n.id);
1363
- const warn = supersededBy[n.id]
1364
- ? ` ⚠ SUPERSEDED by ${supersededBy[n.id]} — do not follow`
1380
+ // The digest is AMBIENT context (the prompt and session-start hooks inject
1381
+ // it unasked, a handful of slots wide), so a superseded node is dropped
1382
+ // rather than flagged — its replacement is already pulled in by the
1383
+ // supersession fixups above. A correction's explicit pin still renders
1384
+ // it, flagged (issue-spor-superseded-context-injected-as-live).
1385
+ if (isSuperseded(n) && !pinned.has(n.id)) return;
1386
+ const warn = isSuperseded(n)
1387
+ ? ` ⚠ ${supersededLabel(n)} — do not follow`
1365
1388
  : resolutionWarn(n);
1366
1389
  const proj = n.project ? `, ${n.project}` : "";
1367
1390
  lines.push(`- **${n.id} — ${n.title}** (${n.type}${proj}, ${n.date}${statusTag(n)}${crossTag(n)}${machineAuthorshipTag(n)}${tag ? `, ${tag}` : ""}): ${n.summary}${warn}`);
@@ -1846,6 +1869,17 @@ function validateGraphFiles(files, seedSchemas = [], opts = {}) {
1846
1869
  }
1847
1870
  }
1848
1871
 
1872
+ // A `status: superseded` with no inbound `supersedes` edge names no
1873
+ // replacement, so no reader can follow it to what is now in force
1874
+ // (issue-spor-superseded-context-injected-as-live).
1875
+ const supersededTargets = new Set();
1876
+ for (const n of Object.values(nodes)) for (const e of n.edges) if (e.type === "supersedes") supersededTargets.add(e.to);
1877
+ for (const n of Object.values(nodes)) {
1878
+ if (String(n.status ?? "").toLowerCase() === "superseded" && !supersededTargets.has(n.id)) {
1879
+ warnings.push(`${n.file}: status superseded but no node supersedes it — add a supersedes edge from its replacement so readers can find what is in force`);
1880
+ }
1881
+ }
1882
+
1849
1883
  const byType = {};
1850
1884
  for (const n of Object.values(nodes)) byType[n.type] = (byType[n.type] ?? 0) + 1;
1851
1885
 
package/lib/tar.js CHANGED
@@ -54,24 +54,61 @@ function splitUstarName(name) {
54
54
  function tarHeader(name, size, mtime) {
55
55
  const split = splitUstarName(name);
56
56
  if (!split) throw new Error(`ustar: '${name}' has no /-boundary split that fits prefix(155)+name(100)`);
57
+ return rawTarHeader(split.name, split.prefix, size, mtime, "0");
58
+ }
59
+
60
+ // One 512-byte ustar header block with the name/prefix fields already split.
61
+ function rawTarHeader(name, prefix, size, mtime, typeflag) {
57
62
  const buf = Buffer.alloc(512);
58
- buf.write(split.name, 0, 100, "utf8");
63
+ buf.write(name, 0, 100, "utf8");
59
64
  buf.write("0000644\0", 100); // mode
60
65
  buf.write("0000000\0", 108); // uid
61
66
  buf.write("0000000\0", 116); // gid
62
67
  buf.write(size.toString(8).padStart(11, "0") + "\0", 124);
63
68
  buf.write(mtime.toString(8).padStart(11, "0") + "\0", 136);
64
69
  buf.write(" ", 148); // chksum: spaces while summing
65
- buf.write("0", 156); // typeflag: regular file
70
+ buf.write(typeflag, 156); // '0' regular file, 'x' pax extended header
66
71
  buf.write("ustar\0", 257);
67
72
  buf.write("00", 263);
68
- if (split.prefix) buf.write(split.prefix, 345, 155, "utf8");
73
+ if (prefix) buf.write(prefix, 345, 155, "utf8");
69
74
  let sum = 0;
70
75
  for (const b of buf) sum += b;
71
76
  buf.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
72
77
  return buf;
73
78
  }
74
79
 
80
+ // A pax extended-header record, "<len> <key>=<value>\n", where <len> counts the
81
+ // whole record including its own digits.
82
+ function paxRecord(key, value) {
83
+ const body = ` ${key}=${value}\n`;
84
+ const bodyLen = Buffer.byteLength(body, "utf8");
85
+ let digits = 1;
86
+ while (String(bodyLen + digits).length !== digits) digits++;
87
+ return `${bodyLen + digits}${body}`;
88
+ }
89
+
90
+ // The header bytes for one archive entry — the byte-faithful twin of the
91
+ // server's tarEntryHeader (spor-server server/rest.js). A path the ustar prefix
92
+ // split represents gets the plain ustar header; one it can't (a node id too long
93
+ // for the 100-byte name field) gets a POSIX pax extended header carrying the
94
+ // full `path`, then the regular header under a short deterministic stub name
95
+ // (issue-spor-export-drops-ids-over-ustar-name-limit).
96
+ function tarEntryHeader(name, size, mtime) {
97
+ const split = splitUstarName(name);
98
+ if (split) return rawTarHeader(split.name, split.prefix, size, mtime, "0");
99
+ const dir = name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : "";
100
+ const hash = require("crypto").createHash("sha1").update(name).digest("hex").slice(0, 16);
101
+ const stub = `${dir ? `${dir}/` : ""}longname-${hash}`;
102
+ const pax = Buffer.from(paxRecord("path", name), "utf8");
103
+ const pad = (512 - (pax.length % 512)) % 512;
104
+ return Buffer.concat([
105
+ rawTarHeader(`${dir ? `${dir}/` : ""}PaxHeaders/${hash}`, "", pax.length, mtime, "x"),
106
+ pax,
107
+ Buffer.alloc(pad),
108
+ rawTarHeader(stub, "", size, mtime, "0"),
109
+ ]);
110
+ }
111
+
75
112
  // Concatenate the descriptors ({name, abs}) into a ustar archive Buffer: a
76
113
  // header per file, its bytes, padding up to the next 512 boundary, then two
77
114
  // closing zero blocks. mtime mirrors the server (file mtime, second precision).
@@ -80,7 +117,7 @@ function buildTarball(descriptors) {
80
117
  for (const d of descriptors) {
81
118
  const data = fs.readFileSync(d.abs);
82
119
  const mtime = Math.max(0, Math.floor(fs.statSync(d.abs).mtimeMs / 1000));
83
- parts.push(tarHeader(d.name, data.length, mtime));
120
+ parts.push(tarEntryHeader(d.name, data.length, mtime));
84
121
  parts.push(data);
85
122
  const pad = (512 - (data.length % 512)) % 512;
86
123
  if (pad) parts.push(Buffer.alloc(pad));
@@ -90,27 +127,17 @@ function buildTarball(descriptors) {
90
127
  }
91
128
 
92
129
  // The export entry list for a graph home's nodes/ dir, mirroring the server's
93
- // selection: every *.md file, sorted by name, as a `nodes/<name>` entry. Most
94
- // long ids are rescued by the prefix split (splitUstarName); an entry with no
95
- // viable split at all is dropped and counted (a path with no viable split is
96
- // still unrepresentable in this archive format).
130
+ // selection: every *.md file, sorted by name, as a `nodes/<name>` entry. A long
131
+ // id the prefix split can't represent rides a pax header (tarEntryHeader), so
132
+ // nothing is dropped; `skipped` stays in the return shape (always 0) for callers.
97
133
  function collectNodeEntries(nodesDir) {
98
134
  const names = fs
99
135
  .readdirSync(nodesDir, { withFileTypes: true })
100
136
  .filter((d) => d.isFile() && d.name.endsWith(".md"))
101
137
  .map((d) => d.name)
102
138
  .sort();
103
- const descriptors = [];
104
- let skipped = 0;
105
- for (const name of names) {
106
- const entry = `nodes/${name}`;
107
- if (!splitUstarName(entry)) {
108
- skipped++;
109
- continue;
110
- }
111
- descriptors.push({ name: entry, abs: path.join(nodesDir, name) });
112
- }
113
- return { descriptors, skipped };
139
+ const descriptors = names.map((name) => ({ name: `nodes/${name}`, abs: path.join(nodesDir, name) }));
140
+ return { descriptors, skipped: 0 };
114
141
  }
115
142
 
116
143
  // The whole local export in one call: { buffer, count, skipped } where buffer
@@ -141,24 +168,52 @@ function tarField(header, start, len) {
141
168
  // `prefix` field (the tarHeader/splitUstarName rescue for a long path) is
142
169
  // rejoined as `prefix + "/" + name`, matching how a real ustar reader
143
170
  // reconstructs the full path — same reconstruction rule splitUstarName's
144
- // comment describes for why the split can only land on an existing "/".
171
+ // comment describes for why the split can only land on an existing "/". A pax
172
+ // extended header (typeflag 'x') is consumed, not returned: its `path` record
173
+ // names the entry that follows (the tarEntryHeader rescue for an id too long
174
+ // for any ustar split, issue-spor-export-drops-ids-over-ustar-name-limit).
145
175
  function extract(buf) {
146
176
  const entries = [];
147
177
  let off = 0;
178
+ let paxPath = null; // a pending pax `path` record, applied to the NEXT entry only
148
179
  while (off + 512 <= buf.length) {
149
180
  const header = buf.subarray(off, off + 512);
150
181
  if (header.every((b) => b === 0)) break; // end-of-archive zero block
151
182
  const prefix = tarField(header, 345, 155);
152
183
  const name = prefix ? `${prefix}/${tarField(header, 0, 100)}` : tarField(header, 0, 100);
153
184
  const size = parseInt(tarField(header, 124, 12).trim() || "0", 8) || 0;
154
- const typeflag = header[156]; // 0x30 '0' or 0x00 → regular file
185
+ const typeflag = header[156]; // 0x30 '0' or 0x00 → regular file; 0x78 'x' → pax
155
186
  off += 512; // advance past the header to the data
156
- if (name && (typeflag === 0x30 || typeflag === 0)) {
157
- entries.push({ name, data: Buffer.from(buf.subarray(off, off + size)) });
187
+ if (typeflag === 0x78) {
188
+ paxPath = parsePaxPath(buf.subarray(off, off + size));
189
+ } else {
190
+ if (typeflag === 0x30 || typeflag === 0) {
191
+ const full = paxPath || name;
192
+ if (full) entries.push({ name: full, data: Buffer.from(buf.subarray(off, off + size)) });
193
+ }
194
+ paxPath = null;
158
195
  }
159
196
  off += Math.ceil(size / 512) * 512; // skip the data + its padding to the next block
160
197
  }
161
198
  return entries;
162
199
  }
163
200
 
164
- module.exports = { tarHeader, splitUstarName, buildTarball, collectNodeEntries, exportNodesDir, extract };
201
+ // The `path` value from a pax extended-header body ("<len> key=value\n"...), or
202
+ // null when it carries none.
203
+ function parsePaxPath(data) {
204
+ let pos = 0;
205
+ let found = null;
206
+ while (pos < data.length) {
207
+ const sp = data.indexOf(0x20, pos);
208
+ if (sp < 0) break;
209
+ const len = parseInt(data.toString("utf8", pos, sp), 10);
210
+ if (!(len > 0)) break;
211
+ const rec = data.toString("utf8", sp + 1, pos + len - 1); // drop the trailing \n
212
+ const eq = rec.indexOf("=");
213
+ if (eq > 0 && rec.slice(0, eq) === "path") found = rec.slice(eq + 1);
214
+ pos += len;
215
+ }
216
+ return found;
217
+ }
218
+
219
+ module.exports = { tarHeader, tarEntryHeader, paxRecord, splitUstarName, buildTarball, collectNodeEntries, exportNodesDir, extract };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "description": "Spor — a shared memory substrate for teams and agents. Decisions, their reasons, and the traces they leave. Knowledge-graph context compiler: session-start briefings, per-prompt digests, capture at discovery, end-of-session distillation, decision queue.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Anthony Allen",
@@ -165,7 +165,7 @@ function compactNodeLine(line) {
165
165
  const [, id, title, meta, rest] = m;
166
166
  const warning = rest.match(/ ⚠ .+$/);
167
167
  const summary = warning ? rest.slice(0, warning.index).trim() : rest.trim();
168
- const statusMatch = meta.match(/\b(resolved|done|rejected|abandoned|answered)\b/i);
168
+ const statusMatch = meta.match(/\b(resolved|done|rejected|abandoned|answered|superseded|settled)\b/i);
169
169
  const status = statusMatch ? ` (${statusMatch[1].toLowerCase()})` : "";
170
170
  return `- ${id}: ${title}${status} — ${summary}${warning ? warning[0] : ""}`;
171
171
  }
@@ -174,7 +174,15 @@ function microDigest(digest, maxNodes = MICRO_MAX_NODES, maxBytes = MICRO_MAX_BY
174
174
  const { nodes, corrections } = parseDigest(digest);
175
175
  if (!nodes.length) return u.byteHead(digest, maxBytes);
176
176
  let out = "Spor context (top matches; run /spor:brief for full):\n";
177
- for (const line of nodes.slice(0, maxNodes)) out += `${compactNodeLine(line)}\n`;
177
+ // Whole node lines only: a line's ⚠ note (superseded / resolved) sits at
178
+ // its END, so a mid-line byte cut would inject the node minus the warning
179
+ // that says not to follow it (issue-spor-superseded-context-injected-as-live).
180
+ // The first line always goes in (byteHead below still bounds it).
181
+ for (const [i, line] of nodes.slice(0, maxNodes).entries()) {
182
+ const l = `${compactNodeLine(line)}\n`;
183
+ if (i > 0 && Buffer.byteLength(out + l, "utf8") > maxBytes) break;
184
+ out += l;
185
+ }
178
186
  if (corrections.length) {
179
187
  out += "\nStanding corrections:\n";
180
188
  for (const line of corrections) out += `${line}\n`;