@sporhq/spor 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "name": "spor",
3
3
  "displayName": "Spor Context Compiler",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
- "version": "0.18.0",
5
+ "version": "0.18.2",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "spor",
3
+ "version": "0.18.2",
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
+ "author": {
6
+ "name": "Spor",
7
+ "url": "https://github.com/sporhq/spor"
8
+ },
9
+ "homepage": "https://sporhq.io",
10
+ "repository": "https://github.com/sporhq/spor",
11
+ "license": "Apache-2.0",
12
+ "keywords": [
13
+ "knowledge-graph",
14
+ "context",
15
+ "memory",
16
+ "agents",
17
+ "spor",
18
+ "provenance"
19
+ ],
20
+ "skills": "./skills/",
21
+ "interface": {
22
+ "displayName": "Spor Context Compiler",
23
+ "shortDescription": "Graph-backed briefings, captures, and queue triage for Codex.",
24
+ "longDescription": "Spor keeps durable project context in a typed knowledge graph, then helps Codex brief tasks, capture follow-up work, correct stale context, and work the decision queue.",
25
+ "developerName": "Spor",
26
+ "category": "Productivity",
27
+ "capabilities": [
28
+ "Knowledge graph",
29
+ "Task briefings",
30
+ "Queue triage"
31
+ ],
32
+ "websiteURL": "https://sporhq.io",
33
+ "defaultPrompt": [
34
+ "Brief this Spor task before I start work.",
35
+ "Capture this follow-up in the graph.",
36
+ "Show me what is next in the Spor queue."
37
+ ],
38
+ "brandColor": "#3B82F6"
39
+ }
40
+ }
package/API.md CHANGED
@@ -402,7 +402,7 @@ endpoint is the REST twin of a core call:
402
402
  | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; the node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open gardener findings about the node ride along as `open_findings`, and a node marked stale by an inbound supersedes edge as `superseded_by`. All enrichment is additive top-level keys; ignore unknown ones |
403
403
  | `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) |
404
404
  | `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` |
405
- | `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. Entries are applied **sequentially** and each is fully validated before the next — including the completion-resolver gate that runs on create (GRAPH.md "the resolver gate") — so a born-terminal node (`done` task / `resolved` issue) must have its resolving `decision`/`artifact` EARLIER in the same batch (**resolver-first ordering**; the batch does not defer the gate to end-of-batch, dec-spor-batch-create-gate-resolver-first-ordering). The 207 is partial-success: entries already applied before a later entry's failure are not rolled back |
405
+ | `POST /v1/nodes` | `spor put-node`, 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. Entries are applied **sequentially** and each is fully validated before the next — including the completion-resolver gate that runs on create (GRAPH.md "the resolver gate") — so a born-terminal node (`done` task / `resolved` issue) must have its resolving `decision`/`artifact` EARLIER in the same batch (**resolver-first ordering**; the batch does not defer the gate to end-of-batch, dec-spor-batch-create-gate-resolver-first-ordering). The 207 is partial-success: entries already applied before a later entry's failure are not rolled back |
406
406
  | `POST /v1/nodes/{id}/edges` `{type, to, attrs?}` | scripts, mechanical writers | `add_edge` semantics (§1): normalize/flip, dedupe, append — no revision echo. Optional `attrs` adds trailing flat edge attributes (e.g. a per-assignment `profile:` override); re-adding the same edge with different attrs upserts the set. Adding a review-outcome edge (`reviewed-by`/`changes-requested-by`/`review-requested`) flips a sibling review edge to the same person in place — the one-call submit-review primitive |
407
407
  | `DELETE /v1/nodes/{id}/edges` `{type, to}` | scripts, mechanical writers | `remove_edge` semantics (§1): the withdrawal twin of the POST above — drop one typed edge by `{type, to}`, normalize/flip exactly as `add_edge` (an inverse form removes the canonical edge on the *other* node and echoes its id), no revision echo. A missing edge is an idempotent `skipped`. For *withdrawing* a relationship the review flip can't express — a pulled review request, a dismissed review |
408
408
  | `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) |
@@ -7,7 +7,18 @@ is just a manifest over `bin/spor-hook.js`.
7
7
  ## Install
8
8
 
9
9
  1. Clone this repo somewhere stable, e.g. `~/tools/spor`.
10
- 2. Resolve the path placeholder and install the manifest:
10
+ 2. Install the Codex plugin, hook manifest, and backfill custom agent:
11
+
12
+ ```sh
13
+ ~/tools/spor/bin/spor install codex
14
+ ```
15
+
16
+ This registers this checkout as the `spor` Codex marketplace, runs
17
+ `codex plugin add spor@spor`, resolves the hook path placeholder, and keeps
18
+ the custom agent sourced from `agents/backfill.md`.
19
+
20
+ 3. Manual hook-only install, if you are debugging the adapter without the Codex
21
+ plugin:
11
22
 
12
23
  ```sh
13
24
  SPOR_ROOT=~/tools/spor
@@ -18,7 +29,8 @@ is just a manifest over `bin/spor-hook.js`.
18
29
  (Or merge into an existing `~/.codex/hooks.json` / `[hooks]` table in
19
30
  `~/.codex/config.toml`. Per-repo installs go in `<repo>/.codex/hooks.json`.)
20
31
 
21
- 3. Add the backfill custom agent:
32
+ 4. Add the backfill custom agent manually, only if you skipped
33
+ `spor install codex`:
22
34
 
23
35
  ```sh
24
36
  mkdir -p ~/.codex/agents
@@ -31,27 +43,20 @@ is just a manifest over `bin/spor-hook.js`.
31
43
  } > ~/.codex/agents/spor-backfill.toml
32
44
  ```
33
45
 
34
- `spor install codex` performs both steps automatically and keeps the custom
35
- agent sourced from `agents/backfill.md`.
36
-
37
- 4. Approve the hooks in Codex's `/hooks` trust prompt on first run.
38
- 5. Environment (the same variables on every host):
46
+ 5. Approve the hooks in Codex's `/hooks` trust prompt on first run.
47
+ 6. Environment (the same variables on every host):
39
48
 
40
49
  ```sh
41
50
  export SPOR_SERVER=https://spor.example.com # remote mode
42
51
  export SPOR_TOKEN=spor_pat_...
43
- # Distiller backend Codex hosts usually won't have the claude CLI.
44
- # Contract: prompt on stdin, response on stdout.
45
- export SPOR_DISTILL_CMD='codex exec -'
46
- # Capture-nudge backend — same contract, but it runs synchronously in the
47
- # tool loop, so prefer a fast model. SPOR_NUDGE=0 disables it.
48
- export SPOR_NUDGE_CMD='codex exec -'
52
+ # Capture-nudge defaults to `codex exec --model gpt-5.4-mini -`; distill
53
+ # defaults to `codex exec -`. Set SPOR_NUDGE_CMD / SPOR_DISTILL_CMD only to
54
+ # override those defaults; SPOR_NUDGE=0 disables the nudge.
49
55
  ```
50
56
 
51
- (Legacy `SUBSTRATE_*` names are still read.) Without `SPOR_DISTILL_CMD` /
52
- `SPOR_NUDGE_CMD` both default to `claude -p --model haiku`, which is fine if
53
- the claude CLI is installed. See [adapters/README.md](../README.md) for the
54
- nudge bounds (`SPOR_NUDGE_MAX`, `SPOR_NUDGE_TIMEOUT`).
57
+ (Legacy `SUBSTRATE_*` names are still read.) See
58
+ [adapters/README.md](../README.md) for the nudge bounds (`SPOR_NUDGE_MAX`,
59
+ `SPOR_NUDGE_TIMEOUT`).
55
60
 
56
61
  ## Event mapping
57
62
 
package/bin/spor-hook.js CHANGED
@@ -153,7 +153,7 @@ async function main() {
153
153
  // `enabled:false` / `mode:off` makes every hook a no-op (exit 0, no output)
154
154
  // so an unrelated side project doesn't pollute the shared graph neighborhood;
155
155
  // default-enabled, so a repo without config is byte-identical to before.
156
- const cfg = u.useConfig({ cwd: payload.cwd || process.cwd() });
156
+ const cfg = u.useConfig({ cwd: payload.cwd || process.cwd(), host });
157
157
  if (cfg.warnings.length) {
158
158
  try {
159
159
  const log = u.makeLogger(path.join(u.graphHome(), "journal", "remote.log"), "config: ");
package/bin/spor.js CHANGED
@@ -747,6 +747,191 @@ function gitBlobSha(buf) {
747
747
  return h.digest("hex");
748
748
  }
749
749
 
750
+ // --- spor put-node: full validated node writes -----------------------------
751
+ // The shell twin of MCP put_node / REST POST /v1/nodes: write a complete node
752
+ // markdown file (frontmatter + body) through the same create/update collision
753
+ // policy instead of dropping to raw REST from scripts and skills.
754
+ function readPutNodeInput(input) {
755
+ if (!input || input === "-") {
756
+ try {
757
+ return { raw: fs.readFileSync(0, "utf8"), label: "stdin" };
758
+ } catch (e) {
759
+ return { error: `could not read stdin: ${e.message}` };
760
+ }
761
+ }
762
+ try {
763
+ return { raw: fs.readFileSync(input, "utf8"), label: input };
764
+ } catch (e) {
765
+ return { error: `could not read ${input}: ${e.message}` };
766
+ }
767
+ }
768
+
769
+ function parsePutNode(raw, label) {
770
+ const graphLib = require(path.join(ROOT, "lib", "graph.js"));
771
+ let first;
772
+ try {
773
+ first = graphLib.parseFrontmatter(raw, label || "incoming.md");
774
+ } catch (e) {
775
+ return { error: `invalid node: ${e.message}` };
776
+ }
777
+ if (!first.id) return { error: "invalid node: missing id" };
778
+ if (!NODE_ID_RE.test(first.id)) return { error: `bad node id '${first.id}' — expected kebab-case` };
779
+ try {
780
+ return { node: graphLib.parseFrontmatter(raw, `${first.id}.md`) };
781
+ } catch (e) {
782
+ return { error: `invalid node: ${e.message}` };
783
+ }
784
+ }
785
+
786
+ function normalizeIfExists(raw) {
787
+ const value = raw == null ? "error" : String(raw).trim().toLowerCase();
788
+ if (["error", "skip", "update"].includes(value)) return { ok: true, value };
789
+ return { ok: false, error: `--if-exists must be one of: error, skip, update` };
790
+ }
791
+
792
+ function renderPutNodeResult(res, json) {
793
+ if (json) {
794
+ out(JSON.stringify(res || {}, null, 2));
795
+ return;
796
+ }
797
+ const status = (res && res.status) || "ok";
798
+ const id = res && res.id ? res.id : "(unknown)";
799
+ const rev = res && res.revision ? ` @ ${res.revision}` : "";
800
+ out(status === "skipped" ? `put-node skipped: ${id}${rev}` : `put-node ${status}: ${id}${rev}`);
801
+ for (const w of (res && res.warnings) || []) err(` warning: ${w}`);
802
+ }
803
+
804
+ function putNodeEntryError(res0, httpStatus, prefix = "put-node") {
805
+ const parts = [];
806
+ if (res0 && res0.message) parts.push(res0.message);
807
+ if (res0 && res0.code && !parts.includes(res0.code)) parts.push(res0.code);
808
+ if (res0 && Array.isArray(res0.details)) parts.push(...res0.details);
809
+ if (res0 && res0.revision) parts.push(`current revision: ${res0.revision}`);
810
+ return `${prefix} error ${httpStatus}${parts.length ? `: ${parts.join("; ")}` : ""}`;
811
+ }
812
+
813
+ function copyNodeFilesForValidation(srcNodes, dstNodes, targetId, raw) {
814
+ fs.mkdirSync(dstNodes, { recursive: true });
815
+ for (const ent of fs.readdirSync(srcNodes, { withFileTypes: true })) {
816
+ if (!ent.isFile() || !ent.name.endsWith(".md")) continue;
817
+ if (ent.name === `${targetId}.md`) continue;
818
+ fs.copyFileSync(path.join(srcNodes, ent.name), path.join(dstNodes, ent.name));
819
+ }
820
+ fs.writeFileSync(path.join(dstNodes, `${targetId}.md`), raw);
821
+ }
822
+
823
+ function validatePutNodeLocal(nodesDir, node, raw) {
824
+ const graphLib = require(path.join(ROOT, "lib", "graph.js"));
825
+ let g;
826
+ try {
827
+ g = graphLib.loadGraph(nodesDir);
828
+ } catch (e) {
829
+ return { error: `could not load graph: ${e.message}` };
830
+ }
831
+ const v = graphLib.validateNode(g, node);
832
+ if (!v.ok) return { error: `invalid node:\n ${v.errors.join("\n ")}` };
833
+
834
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "spor-put-node-"));
835
+ const tmpNodes = path.join(tmp, "nodes");
836
+ try {
837
+ copyNodeFilesForValidation(nodesDir, tmpNodes, node.id, raw);
838
+ const vg = graphLib.validateGraph(tmpNodes);
839
+ if (vg.errors && vg.errors.length) return { error: `invalid graph after put-node:\n ${vg.errors.join("\n ")}` };
840
+ return { ok: true, warnings: vg.warnings || [] };
841
+ } finally {
842
+ fs.rmSync(tmp, { recursive: true, force: true });
843
+ }
844
+ }
845
+
846
+ async function cmdPutNode(cfg, { values, positionals }) {
847
+ const input = positionals[0] || "-";
848
+ const ifExists = normalizeIfExists(values["if-exists"]);
849
+ if (!ifExists.ok) {
850
+ err(ifExists.error);
851
+ return 1;
852
+ }
853
+ const policy = ifExists.value;
854
+ const revision = values.revision || null;
855
+ if (policy === "update" && !revision) {
856
+ err("put-node update requires --revision from 'spor get <id> --json'");
857
+ return 1;
858
+ }
859
+ if (policy !== "update" && revision) {
860
+ err("--revision is only valid with --if-exists update");
861
+ return 1;
862
+ }
863
+
864
+ const inputRes = readPutNodeInput(input);
865
+ if (inputRes.error) {
866
+ err(inputRes.error);
867
+ return 1;
868
+ }
869
+ const raw = inputRes.raw;
870
+ const parsed = parsePutNode(raw, inputRes.label);
871
+ if (parsed.error) {
872
+ err(parsed.error);
873
+ return 1;
874
+ }
875
+ const id = parsed.node.id;
876
+
877
+ if (cfg.mode() === "remote") {
878
+ const entry = { node: raw, if_exists: policy };
879
+ if (revision) entry.revision = revision;
880
+ const r = await remote.post(cfg, "/v1/nodes", { nodes: [entry] }, { timeoutMs: 15000 });
881
+ if (r.transport) {
882
+ err(`offline — could not reach server (${r.error})`);
883
+ return 1;
884
+ }
885
+ const res0 = r.json && r.json.results && r.json.results[0];
886
+ if (!(res0 && res0.ok)) {
887
+ const top = r.json && r.json.error;
888
+ if (top && !res0) err(`put-node error ${r.status}${top.message ? `: ${top.message}` : ""}`);
889
+ else err(putNodeEntryError(res0, r.status));
890
+ return 1;
891
+ }
892
+ renderPutNodeResult(res0, !!values.json);
893
+ return 0;
894
+ }
895
+
896
+ const nodesDir = cfg.nodesDir();
897
+ if (!fs.existsSync(nodesDir)) {
898
+ err(`no graph at ${nodesDir} — run 'spor init' first`);
899
+ return 1;
900
+ }
901
+ const file = path.join(nodesDir, `${id}.md`);
902
+ const exists = fs.existsSync(file);
903
+ if (policy === "error" && exists) {
904
+ err(`node already exists: ${id} (use --if-exists update with --revision, or --if-exists skip)`);
905
+ return 1;
906
+ }
907
+ if (policy === "skip" && exists) {
908
+ const res = { ok: true, status: "skipped", id, revision: gitBlobSha(fs.readFileSync(file)), warnings: [] };
909
+ renderPutNodeResult(res, !!values.json);
910
+ return 0;
911
+ }
912
+ if (policy === "update") {
913
+ if (!exists) {
914
+ err(`no such node: ${id}`);
915
+ return 1;
916
+ }
917
+ const current = gitBlobSha(fs.readFileSync(file));
918
+ if (current !== revision) {
919
+ err(`put-node conflict: stale revision for ${id}; current revision: ${current}`);
920
+ return 1;
921
+ }
922
+ }
923
+
924
+ const valid = validatePutNodeLocal(nodesDir, parsed.node, raw);
925
+ if (valid.error) {
926
+ err(valid.error);
927
+ return 1;
928
+ }
929
+ fs.writeFileSync(file, raw);
930
+ const res = { ok: true, status: exists ? "updated" : "created", id, revision: gitBlobSha(Buffer.from(raw)), warnings: valid.warnings || [] };
931
+ renderPutNodeResult(res, !!values.json);
932
+ return 0;
933
+ }
934
+
750
935
  // --- spor blame / commits: commit-sha -> nodes reverse lookup ---------------
751
936
  // (task-spor-blame-commit-lookup-cli-verb) The shell verb over the commit->node
752
937
  // reverse index: which decisions/tasks/issues reference a git commit in their
@@ -4685,7 +4870,7 @@ function cmdLink(cfg, { positionals }) {
4685
4870
  const HOSTS = {
4686
4871
  claude: { kind: "claude", label: "Claude Code" },
4687
4872
  codex: {
4688
- kind: "hooks",
4873
+ kind: "codex",
4689
4874
  label: "Codex CLI",
4690
4875
  src: ["adapters", "codex", "hooks.json"],
4691
4876
  user: [".codex", "hooks.json"],
@@ -4722,6 +4907,12 @@ function claudeCmd() {
4722
4907
  return process.env.SPOR_CLAUDE_CMD || "claude";
4723
4908
  }
4724
4909
 
4910
+ // The Codex CLI binary, overridable for tests. Codex owns plugin install state,
4911
+ // so all plugin shell-outs route through this seam instead of writing its cache.
4912
+ function codexCmd() {
4913
+ return process.env.SPOR_CODEX_CMD || "codex";
4914
+ }
4915
+
4725
4916
  function spawnPortableSync(cmd, args, opts = {}) {
4726
4917
  if (process.platform !== "win32" || opts.shell) return spawnSync(cmd, args, opts);
4727
4918
  const resolved = u.whichSync(cmd) || cmd;
@@ -5079,6 +5270,37 @@ function installClaude(scope, dryRun) {
5079
5270
  return 0;
5080
5271
  }
5081
5272
 
5273
+ // Codex CLI: install the repo as a Codex plugin via its marketplace commands,
5274
+ // then keep the hook manifest + backfill custom agent installed. The plugin
5275
+ // manifest intentionally does not carry hooks (Codex plugin validation rejects
5276
+ // that field), so both halves matter.
5277
+ function installCodex(scope, dryRun) {
5278
+ const cmd = codexCmd();
5279
+ const mpArgs = ["plugin", "marketplace", "add", ROOT];
5280
+ const pluginArgs = ["plugin", "add", "spor@spor"];
5281
+ if (dryRun) {
5282
+ out(`would run: ${cmd} ${mpArgs.join(" ")}`);
5283
+ out(`would run: ${cmd} ${pluginArgs.join(" ")}`);
5284
+ return installHookHost(HOSTS.codex, scope, true);
5285
+ }
5286
+ if (cmd === "codex" && !hasCmd("codex")) {
5287
+ err("codex CLI not on PATH — install Codex, then re-run 'spor install codex'.");
5288
+ return 1;
5289
+ }
5290
+ const mp = spawnPortableSync(cmd, mpArgs, { encoding: "utf8" });
5291
+ if (mp.status !== 0 && !/already|exists|known/i.test((mp.stderr || "") + (mp.stdout || ""))) {
5292
+ err(`codex plugin marketplace add failed: ${(mp.stderr || mp.stdout || "").trim() || "unknown error"}`);
5293
+ return 1;
5294
+ }
5295
+ const plugin = spawnPortableSync(cmd, pluginArgs, { stdio: "inherit" });
5296
+ if (plugin.status !== 0) {
5297
+ err(`codex plugin add failed (exit ${plugin.status == null ? "?" : plugin.status})`);
5298
+ return 1;
5299
+ }
5300
+ out("installed spor@spor into Codex.");
5301
+ return installHookHost(HOSTS.codex, scope, false);
5302
+ }
5303
+
5082
5304
  // JSON-hook hosts (codex/cursor/copilot/gemini): render + merge into the target.
5083
5305
  function installHookHost(spec, scope, dryRun) {
5084
5306
  const target = targetPath(spec, scope);
@@ -5181,6 +5403,7 @@ async function cmdInstall(cfg, { values, positionals: pos }) {
5181
5403
  const spec = HOSTS[host];
5182
5404
  let r;
5183
5405
  if (spec.kind === "claude") r = installClaude(scope, dryRun);
5406
+ else if (spec.kind === "codex") r = installCodex(scope, dryRun);
5184
5407
  else if (spec.kind === "plugin") r = installPluginHost(spec, scope, dryRun);
5185
5408
  else r = installHookHost(spec, scope, dryRun);
5186
5409
  if (r !== 0) rc = r;
@@ -5201,10 +5424,12 @@ async function cmdInstall(cfg, { values, positionals: pos }) {
5201
5424
  // (issue-spor-upgrade-no-plugin-refresh) An npm bump updates the package on disk
5202
5425
  // but NOT what an agent already loaded: Claude Code runs its OWN cached copy of
5203
5426
  // the plugin, so it keeps running stale skills/hooks until 'plugin update' swaps
5204
- // the copy. The hook hosts (codex/cursor/copilot/gemini/opencode) reference the
5205
- // package by absolute path, so they only go stale if the checkout MOVED — for
5206
- // which re-running the idempotent install refreshes the path. This verb does
5207
- // both in one step and tells the user to restart the session.
5427
+ // the copy. Codex also caches an installed plugin copy, while its hooks still
5428
+ // reference this checkout by absolute path. Re-running the idempotent install
5429
+ // refreshes Codex's marketplace/plugin cache and rewrites the hook path. The
5430
+ // other hook hosts reference the package by absolute path, so they only go stale
5431
+ // if the checkout MOVED. This verb does both in one step and tells the user to
5432
+ // restart the session.
5208
5433
 
5209
5434
  // Refresh Claude Code's loaded plugin (marketplace add to register/repoint the
5210
5435
  // source, then the shared marketplace+plugin update). Returns 0/1.
@@ -5281,6 +5506,7 @@ async function cmdUpgrade(cfg, { values, positionals: pos }) {
5281
5506
  for (const host of hosts) {
5282
5507
  let r;
5283
5508
  if (host === "claude") r = upgradeClaude(scope, dryRun);
5509
+ else if (host === "codex") r = installCodex(scope, dryRun);
5284
5510
  else {
5285
5511
  // Re-running install refreshes the absolute __SPOR_ROOT__ path (a no-op
5286
5512
  // when the path is unchanged; repairs a moved checkout when it is not).
@@ -7777,6 +8003,34 @@ const COMMANDS = {
7777
8003
  examples: ["spor get dec-cc-zero-dep-client", "spor get dec-cc-zero-dep-client --json"],
7778
8004
  run: (cfg, p) => cmdGet(cfg, p),
7779
8005
  },
8006
+ "put-node": {
8007
+ group: "Graph", parse: "strict", args: "[<file>|-]",
8008
+ summary: "write a full node markdown file (local validated write; remote: /v1/nodes)",
8009
+ help:
8010
+ "Create, skip, or update one complete node markdown file (frontmatter + body)\n" +
8011
+ "through the same validated full-node write path as MCP put_node / REST\n" +
8012
+ "POST /v1/nodes. With no file, or with '-', reads the node markdown from stdin.\n\n" +
8013
+ "Collision policy is explicit: --if-exists error (default) rejects an existing\n" +
8014
+ "id, --if-exists skip no-ops on collision, and --if-exists update replaces an\n" +
8015
+ "existing node only when --revision matches the blob SHA you read earlier.\n" +
8016
+ "Get that revision with 'spor get <id> --json'; re-read and retry on conflict.\n\n" +
8017
+ "Remote mode sends one-entry batch put_node to /v1/nodes, so server attribution,\n" +
8018
+ "schema transition gates, edge normalization, and validation all apply. Local\n" +
8019
+ "mode writes nodes/<id>.md after parsing and validating the candidate against a\n" +
8020
+ "temporary graph view, so a malformed full node never lands on disk.",
8021
+ options: {
8022
+ "if-exists": { type: "string", value: "error|skip|update", desc: "collision policy (default: error)" },
8023
+ revision: { type: "string", value: "sha", desc: "required with --if-exists update; from 'spor get <id> --json'" },
8024
+ json: { type: "boolean", desc: "machine-readable result envelope" },
8025
+ },
8026
+ examples: [
8027
+ "spor put-node ./nodes/dec-x.md",
8028
+ "spor get dec-x --json",
8029
+ "spor put-node ./dec-x.md --if-exists update --revision <blob-sha>",
8030
+ "cat ./task-new.md | spor put-node --if-exists error",
8031
+ ],
8032
+ run: (cfg, p) => cmdPutNode(cfg, p),
8033
+ },
7780
8034
  blame: {
7781
8035
  group: "Graph", parse: "strict", args: "<sha> [--repo <slug>]", aliases: ["commits"],
7782
8036
  summary: "which nodes reference a commit (local: graph scan; remote: /v1/commits/<sha>)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
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",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "files": [
29
29
  ".claude-plugin/",
30
+ ".codex-plugin/",
30
31
  "lib/",
31
32
  "bin/",
32
33
  "scripts/engines/",
@@ -252,7 +252,7 @@ async function distill(input) {
252
252
  // runs async on SessionEnd, so it tolerates more than the nudge, but a wedged
253
253
  // CLI should still not hang the SessionEnd hook indefinitely).
254
254
  const timeoutMs = u.cfgNum("distill.timeoutMs", "DISTILL_TIMEOUT", 120000);
255
- const distillCmd = u.cfgStr("distill.cmd", "DISTILL_CMD");
255
+ const distillCmd = u.cfgStr("distill.cmd", "DISTILL_CMD") || u.hostDefaultBackendCmd("distill");
256
256
  if (distillCmd) {
257
257
  backend = `cmd:${distillCmd}`;
258
258
  response = u.runBackendCmd(distillCmd, prompt, { timeoutMs });
@@ -181,7 +181,7 @@ async function nudge({ input, graph, slug, session, file, remote }) {
181
181
  // PostToolUse budget (nudge.timeoutMs / SPOR_NUDGE_TIMEOUT, default 30s — room
182
182
  // for a ~17s claude -p haiku cold boot, well under the host's 60s).
183
183
  const timeoutMs = u.cfgNum("nudge.timeoutMs", "NUDGE_TIMEOUT", 30000);
184
- const nudgeCmd = u.cfgStr("nudge.cmd", "NUDGE_CMD");
184
+ const nudgeCmd = u.cfgStr("nudge.cmd", "NUDGE_CMD") || u.hostDefaultBackendCmd("nudge");
185
185
  if (nudgeCmd) {
186
186
  backend = `cmd:${nudgeCmd}`;
187
187
  response = u.runBackendCmd(nudgeCmd, prompt, { timeoutMs });
@@ -11,6 +11,7 @@ const crypto = require("crypto");
11
11
  const { execFileSync, spawnSync, spawn } = require("child_process");
12
12
 
13
13
  const ROOT = path.resolve(__dirname, "..", "..");
14
+ const CODEX_NUDGE_MODEL = "gpt-5.4-mini";
14
15
 
15
16
  const home = require(path.join(ROOT, "lib", "shell", "home.js"));
16
17
  // The harness vocabulary the capability probe emits — owned by the pure matcher
@@ -25,7 +26,9 @@ const { HARNESS_BINARIES, SPOR_MCP_NAME } = require(path.join(ROOT, "lib", "kern
25
26
  // falls back to the exact env dual-read it replaced, so those paths stay
26
27
  // byte-identical (norm-cc-byte-identical-refactor).
27
28
  let _config = null;
29
+ let _host = null;
28
30
  function useConfig(opts) {
31
+ _host = opts && opts.host ? opts.host : null;
29
32
  _config = require(path.join(ROOT, "lib", "config.js")).loadConfig(opts);
30
33
  return _config;
31
34
  }
@@ -35,6 +38,7 @@ function useConfig(opts) {
35
38
  // (serverBase/bearer/graphHome) honors a file-config or --org tenant instead of
36
39
  // silently falling back to raw env.
37
40
  function setConfig(cfg) {
41
+ _host = null;
38
42
  _config = cfg;
39
43
  return cfg;
40
44
  }
@@ -42,6 +46,7 @@ function config() {
42
46
  return _config;
43
47
  }
44
48
  function clearConfig() {
49
+ _host = null;
45
50
  _config = null; // test hook
46
51
  }
47
52
  // Config-aware string read: the active cascade value, else env dual-read.
@@ -61,6 +66,12 @@ function cfgNum(keyPath, envName, fallback) {
61
66
  return Number.isFinite(n) ? n : fallback;
62
67
  }
63
68
 
69
+ function hostDefaultBackendCmd(kind) {
70
+ if (_host === "codex" && kind === "nudge") return `codex exec --model ${CODEX_NUDGE_MODEL} -`;
71
+ if (_host === "codex" && kind === "distill") return "codex exec -";
72
+ return undefined;
73
+ }
74
+
64
75
  function graphHome() {
65
76
  return _config ? _config.graphHome() : home.graphHome();
66
77
  }
@@ -1068,6 +1079,7 @@ module.exports = {
1068
1079
  clearConfig,
1069
1080
  cfgStr,
1070
1081
  cfgNum,
1082
+ hostDefaultBackendCmd,
1071
1083
  jqNow,
1072
1084
  isoMs,
1073
1085
  isoSeconds,
@@ -1,6 +1,18 @@
1
1
  ---
2
2
  name: spor
3
- description: The operating manual for Spor — a knowledge-graph tool your training does not cover. Load it before any Spor graph operation so you use the right CLI syntax, node and edge format, MCP tools, and REST API instead of rediscovering them: querying or searching the graph, reading or writing nodes, adding edges, capturing or deferring work, running spor CLI commands or Spor MCP tools, working in local vs remote mode, or defining a new node/edge type or schema. It also routes you to the right /spor action skill (defer, brief, next, correct, ask, backfill) for a specific operation, and explains the node/edge mental model when you need it. For FIRST-TIME setup — installing, creating an identity, joining a team graph, or "spor isn't doing anything" — use /spor:onboard instead. When unsure how to operate Spor, consult this before guessing.
3
+ description: >
4
+ The operating manual for Spor — a knowledge-graph tool your training does not
5
+ cover. Load it before any Spor graph operation so you use the right CLI
6
+ syntax, node and edge format, MCP tools, and REST API instead of rediscovering
7
+ them: querying or searching the graph, reading or writing nodes, adding edges,
8
+ capturing or deferring work, running spor CLI commands or Spor MCP tools,
9
+ working in local vs remote mode, or defining a new node/edge type or schema.
10
+ It also routes you to the right /spor action skill (defer, brief, next,
11
+ correct, ask, backfill) for a specific operation, and explains the node/edge
12
+ mental model when you need it. For FIRST-TIME setup — installing, creating an
13
+ identity, joining a team graph, or "spor isn't doing anything" — use
14
+ /spor:onboard instead. When unsure how to operate Spor, consult this before
15
+ guessing.
4
16
  ---
5
17
 
6
18
  # Operate Spor
@@ -80,6 +92,9 @@ rest are mode-specific (`spor status` confirms which mode you're in):
80
92
  spor status # resolved mode, graph, project, identity, health
81
93
  spor next [--project <slug>] # the ranked decision queue — "what's next"
82
94
  spor get <id> # one node by id
95
+ spor put-node [<file>|-] --if-exists <error|skip|update> [--revision <sha>]
96
+ # write a full node markdown file through validated put_node semantics;
97
+ # use `spor get <id> --json` first and pass its revision for updates
83
98
  spor blame <sha> [--repo <s>] # which nodes reference a git commit (alias: spor commits <sha>)
84
99
  spor history <id> [<sha>] # a node's commit lineage (actor/when/what); <sha> = that revision's diff (local git log / GET /v1/nodes/<id>/history)
85
100
  spor schema [<type>] # introspect the live registry (types/prefixes/weights/flags/gates,
@@ -161,6 +176,13 @@ A few of these have enough surface to be worth a sentence:
161
176
  read from git content history, never `updated_at`
162
177
  (dec-spor-git-derived-timestamps). `--project`/`--type`/`--weeks`/`--json`
163
178
  scope and shape it.
179
+ - **`spor put-node`** is the shell twin of MCP `put_node` / REST `POST
180
+ /v1/nodes`: pass a complete node markdown file, or `-`/stdin. Default
181
+ `--if-exists error` rejects collisions; `--if-exists skip` no-ops on an
182
+ existing id; `--if-exists update` requires the `revision` from `spor get <id>
183
+ --json` so updates are optimistic-concurrency checked instead of last-writer
184
+ wins. Prefer `spor edge`/`spor set-status` for narrow mutations; use
185
+ `put-node` for full-node artifacts such as briefing versions or body edits.
164
186
  - **`spor compile`/`spor brief`** are mode-aware: local runs the in-repo
165
187
  compiler, remote dispatches to the server (this is what `/spor:brief` pulls).
166
188
  In local mode add `--project <repo-slug>` to scope to a repo — without it
@@ -148,7 +148,43 @@ referenced prior-art artifact no longer trips this; a genuine non-resolving work
148
148
  product still does. The one thing triage must not do is leave the flag with no
149
149
  decision — it's a question addressed to you.
150
150
 
151
- ## 5. Latent dependenciesrecord missing `blocks` edges
151
+ ## 5. Stale briefing findings recompile the briefing
152
+
153
+ Gardener findings about briefing freshness are not ordinary "close?" chores.
154
+ If a finding says a `brief-*` node is stale, missing `derived-from`
155
+ provenance, has an input-fingerprint mismatch, or has source-set drift, the
156
+ repair is a new briefing version with current provenance. Do not merely
157
+ acknowledge the finding or mark it handled while the stale briefing remains in
158
+ place — that leaves session-start serving bad context.
159
+
160
+ Treat the affected briefing as a build artifact and rebuild it:
161
+
162
+ - Read the finding and the affected `brief-*` node (`spor get <finding-id>` and
163
+ `spor get <brief-id>`). Check its `compiled-for`, `derived-from`, and
164
+ `shaped-by` edges so you know what it was meant to brief and which
165
+ corrections must still apply.
166
+ - Compile/research the current source set for the briefing target. For a
167
+ root/node briefing, run `spor brief <target-id>` for the human body; in a local
168
+ graph, `spor compile --root <target-id> --skeleton` can produce the updated
169
+ provenance skeleton. For a standing project briefing, compile the current
170
+ repo/product context and honor every standing correction named by `shaped-by`.
171
+ - Write the affected `brief-*` node as the next `version:` with the refreshed
172
+ body, exact current `derived-from` inputs, preserved `compiled-for`, and any
173
+ relevant `shaped-by` correction edges. Use `spor put-node <file> --if-exists
174
+ update --revision <sha>` (or MCP `put_node`) so the update is validated and
175
+ optimistic-concurrency checked.
176
+ - Verify the condition clears, or is ready for the gardener to auto-resolve:
177
+ re-read the briefing and, when appropriate, run `spor admin gardener --json`
178
+ to confirm the stale-briefing finding is no longer filed.
179
+ - Only after the repaired briefing exists should the finding be considered
180
+ handled. If the finding still fires, keep it open and report what source or
181
+ correction is still unresolved.
182
+
183
+ This is intentionally more work than closing a stale anchor: stale briefing
184
+ findings affect future agent context, so triage must leave a fresh `brief-*`
185
+ artifact behind.
186
+
187
+ ## 6. Latent dependencies → record missing `blocks` edges
152
188
 
153
189
  Find tasks that should carry a `blocks`/`blocked-by` edge but don't — work the
154
190
  queue surfaces as actionable that actually can't start until something else
@@ -174,7 +210,7 @@ Record a confirmed one as a `blocks` edge from the prerequisite to the dependent
174
210
  (mirroring how an issue records its block). The dependent then leaves the
175
211
  actionable queue until its blocker resolves — which is the point.
176
212
 
177
- ## 6. Open questions → brief the lineage, then answer
213
+ ## 7. Open questions → brief the lineage, then answer
178
214
 
179
215
  This is where triage earns its keep: the queue's `questions` are decisions
180
216
  waiting on a human, and a bare list of them is useless — the reader can't answer
@@ -200,7 +236,7 @@ decide.** For each open question:
200
236
  (If `questions` is empty there's nothing to do here — but it's the step most
201
237
  often forgotten, so check it every pass.)
202
238
 
203
- ## 7. Prioritise → unblockers first
239
+ ## 8. Prioritise → unblockers first
204
240
 
205
241
  Once you understand the dependency shape, set explicit `priority:` (`p1`/`p2`/
206
242
  `p3`) so the queue sequences correctly. The highest-leverage move is to **bump
@@ -224,7 +260,7 @@ genuinely ready work, start/claim it or let `front` decay; don't fake a resolver
224
260
  to undo it. (A bare reference no longer trips this —
225
261
  `issue-spor-queue-held-guard-false-positive-referenced-outcome`.)
226
262
 
227
- ## 8. Present the outcome
263
+ ## 9. Present the outcome
228
264
 
229
265
  Talk to a human, in plain language. Lead with what you did and what to pick up
230
266
  next, one short reason each. Give a compact before/after (queue size, what
@@ -246,11 +282,11 @@ the REST endpoint against the resolved server; in Cowork use the MCP tool. See
246
282
  | Set priority | `spor priority <id> <p1\|p2\|p3\|clear>` (or `POST /v1/nodes/<id>/priority {priority}`) | `set_priority` |
247
283
  | Set status (merged/rejected/resolved/…) | `spor set-status <id> <status>` (or `POST /v1/nodes/<id>/status {status}`) | `set_status` |
248
284
  | Add edge (supersedes/blocks/relates-to/answers) | `spor edge <id> <type> <to>` (or `POST /v1/nodes/<id>/edges {type, to}`) | `add_edge` |
249
- | Edit a field (body) | `POST /v1/nodes {nodes:[{node, if_exists:"update", revision}]}` | `put_node` |
285
+ | Edit a field (body) | `spor put-node <file> --if-exists update --revision <sha>` (or `POST /v1/nodes {nodes:[{node, if_exists:"update", revision}]}`) | `put_node` |
250
286
  | Compile a question's lineage | `spor brief <id>` | `query_graph root_id=<id>` |
251
287
 
252
288
  Edges normalize/flip/dedupe server-side, so write a `blocks` from the
253
- prerequisite's perspective and let it record the inverse. `put_node` needs
289
+ prerequisite's perspective and let it record the inverse. `spor put-node` / `put_node` needs
254
290
  `if_exists:"update"` **and** the current `revision` — without the explicit mode
255
291
  it skips an existing node. Status flips run through the schema `transitions()`
256
292
  gate (and terminal closes through the completion-resolver gate), so a denied