@imunitic/synapse 0.0.1-test.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.
Files changed (40) hide show
  1. package/Index.md.template +23 -0
  2. package/bin/synapse-hook.cjs +19 -0
  3. package/bin/synapse-setup.cjs +420 -0
  4. package/bin/synapse.cjs +20 -0
  5. package/commands/synapse-design-note.md +229 -0
  6. package/commands/synapse-init.md +354 -0
  7. package/commands/synapse-note.md +196 -0
  8. package/commands/synapse-rebuild-diff.md +314 -0
  9. package/commands/synapse-rebuild-full.md +152 -0
  10. package/commands/synapse-status.md +144 -0
  11. package/commands/synapse-task-note.md +133 -0
  12. package/commands/synapse-vault-tidy.md +187 -0
  13. package/harness/claude/hooks.json +54 -0
  14. package/harness/codex/hooks.json +54 -0
  15. package/harness/codex/skills/synapse-design-note/SKILL.md +236 -0
  16. package/harness/codex/skills/synapse-init/SKILL.md +354 -0
  17. package/harness/codex/skills/synapse-note/SKILL.md +212 -0
  18. package/harness/codex/skills/synapse-rebuild-diff/SKILL.md +315 -0
  19. package/harness/codex/skills/synapse-rebuild-full/SKILL.md +149 -0
  20. package/harness/codex/skills/synapse-status/SKILL.md +146 -0
  21. package/harness/codex/skills/synapse-task-note/SKILL.md +133 -0
  22. package/harness/codex/skills/synapse-vault-tidy/SKILL.md +187 -0
  23. package/harness/opencode/plugin/synapse.js +164 -0
  24. package/lib/obsidian-mcp-refresh.cjs +303 -0
  25. package/lib/resolve-binaries.cjs +54 -0
  26. package/package.json +26 -0
  27. package/skills/synapse-node/SKILL.md +211 -0
  28. package/skills/synapse-node-authoring/SKILL.md +188 -0
  29. package/skills/synapse-node-format/SKILL.md +205 -0
  30. package/skills/synapse-orientation/SKILL.md +468 -0
  31. package/skills/synapse-query/SKILL.md +99 -0
  32. package/skills/synapse-task/SKILL.md +261 -0
  33. package/skills/synapse-vault/SKILL.md +107 -0
  34. package/synapse-claude.md +220 -0
  35. package/synapse-fence-languages.conf.template +24 -0
  36. package/synapse-ignore-files.conf.template +45 -0
  37. package/synapse-module-boilerplate.conf.template +24 -0
  38. package/synapse-projects.conf.template +14 -0
  39. package/synapse-prompt-stopwords.conf.template +594 -0
  40. package/synapse.conf.template +23 -0
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart hook: keeps the `obsidian` MCP server registration current,
3
+ // automatically, every session -- the hook-driven replacement for manually
4
+ // running setup-obsidian-mcp.sh. Independent of, and never wired through,
5
+ // fetch-and-run.cjs/synapse-hook: different domain (wiring up the connection
6
+ // to the vault, not Synapse's own code-graph/vault logic), kept separate so
7
+ // a bug here can never risk the core Synapse injection into every turn's
8
+ // context.
9
+ //
10
+ // Node, not a shell script: the only interpreter every hook subprocess is
11
+ // guaranteed to have on PATH is the one Claude Code itself ships with. `jq`
12
+ // and `curl` are gone entirely (native JSON.parse and the built-in `https`
13
+ // module replace them); `claude` itself is still an external dependency,
14
+ // since MCP registration is its own command, not something this hook can do
15
+ // any other way.
16
+ //
17
+ // Every missing precondition is silence, same convention every other Synapse
18
+ // hook already follows -- this never blocks a turn, and produces no stdout
19
+ // (a SessionStart hook's stdout becomes injected context; this is a pure
20
+ // side-effecting maintenance task, nothing worth telling the model). Genuine
21
+ // problems go to stderr only, same as the original script already did.
22
+ //
23
+ // One real, pre-existing limitation, not new here: MCP servers connect at
24
+ // Claude Code startup, before any hook fires, so this can't fix the *current*
25
+ // session's connection -- first-time registration or a cert/key rotation
26
+ // still needs one restart to take effect.
27
+
28
+ "use strict";
29
+
30
+ const fs = require("fs");
31
+ const os = require("os");
32
+ const path = require("path");
33
+ const https = require("https");
34
+ const { spawnSync } = require("child_process");
35
+
36
+ function commandExists(cmd) {
37
+ const res = spawnSync(cmd, ["--version"], { stdio: "ignore" });
38
+ return !(res.error && res.error.code === "ENOENT");
39
+ }
40
+
41
+ // `$VAR`/`${VAR}` expansion looks HOME up through this rather than reading
42
+ // `process.env.HOME` directly, so the one thing standing between this
43
+ // resolving correctly and not on a platform with no `HOME` env var (Windows)
44
+ // is this one substitution -- everything downstream (conf-file tiering,
45
+ // `~`/`$HOME` expansion) is otherwise a direct port of core/conf.zig's
46
+ // algorithm and stays untouched.
47
+ function getVar(name) {
48
+ if (name === "HOME") return os.homedir();
49
+ return process.env[name];
50
+ }
51
+
52
+ // Mirrors core/conf.zig's `get`: one key's raw value from a conf file's
53
+ // text, unexpanded. Comments, blank lines, an `export` prefix, and a
54
+ // matched-quote or trailing-comment unquote, same as a shell would read it.
55
+ // The last assignment wins, same as sourcing would.
56
+ function confGet(text, key) {
57
+ let found = null;
58
+ for (let raw of text.split("\n")) {
59
+ let line = raw.trim();
60
+ if (line.length === 0 || line[0] === "#") continue;
61
+ if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();
62
+ if (!line.startsWith(key)) continue;
63
+ const rest = line.slice(key.length);
64
+ if (rest.length === 0 || rest[0] !== "=") continue;
65
+ found = confUnquote(rest.slice(1).trim());
66
+ }
67
+ return found;
68
+ }
69
+
70
+ function confUnquote(raw) {
71
+ if (raw.length >= 2 && (raw[0] === '"' || raw[0] === "'") && raw[raw.length - 1] === raw[0]) {
72
+ return raw.slice(1, -1);
73
+ }
74
+ const m = raw.match(/[#\s]/);
75
+ return m ? raw.slice(0, m.index) : raw;
76
+ }
77
+
78
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
79
+
80
+ // Mirrors core/conf.zig's `expand`: a leading `~`, `$VAR` and `${VAR}`
81
+ // replaced against `getVar`; everything else (including `${VAR:-default}`,
82
+ // `~user`, and a Windows path's backslashes) left exactly as-is.
83
+ function confExpand(raw) {
84
+ let out = "";
85
+ let i = 0;
86
+ if (raw.length !== 0 && raw[0] === "~" && (raw.length === 1 || raw[1] === "/")) {
87
+ out += getVar("HOME") || "";
88
+ i = 1;
89
+ }
90
+ while (i < raw.length) {
91
+ if (raw[i] === "\\" && raw[i + 1] === "$") {
92
+ out += "$";
93
+ i += 2;
94
+ continue;
95
+ }
96
+ if (raw[i] !== "$") {
97
+ out += raw[i];
98
+ i += 1;
99
+ continue;
100
+ }
101
+ const after = raw.slice(i + 1);
102
+ if (after[0] === "{") {
103
+ const close = after.indexOf("}");
104
+ if (close === -1) {
105
+ out += raw[i];
106
+ i += 1;
107
+ continue;
108
+ }
109
+ const name = after.slice(1, close);
110
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
111
+ out += raw[i];
112
+ i += 1;
113
+ continue;
114
+ }
115
+ out += getVar(name) || "";
116
+ i += 1 + close + 1;
117
+ continue;
118
+ }
119
+ const m = after.match(NAME_RE);
120
+ if (!m) {
121
+ out += raw[i];
122
+ i += 1;
123
+ continue;
124
+ }
125
+ out += getVar(m[0]) || "";
126
+ i += 1 + m[0].length;
127
+ }
128
+ return out;
129
+ }
130
+
131
+ // Mirrors core/conf.zig's `resolveExisting` (tiers 1-2 only -- this hook,
132
+ // like the shell script it replaces, never falls back to the plugin's own
133
+ // bundled template: that template's OBSIDIAN_VAULT_DIR is a placeholder
134
+ // path, not a real vault, and silently trying it would be worse than doing
135
+ // nothing).
136
+ function resolveConfPath(name) {
137
+ const xdg = process.env.XDG_CONFIG_HOME;
138
+ const home = getVar("HOME");
139
+ const candidates = [];
140
+ if (xdg) candidates.push(path.join(xdg, "synapse", name));
141
+ if (home) candidates.push(path.join(home, ".config", "synapse", name));
142
+ if (home) candidates.push(path.join(home, ".claude", name));
143
+ for (const p of candidates) {
144
+ if (fs.existsSync(p)) return p;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ // Mirrors core/conf.zig's `vaultDir`: the env override first, then each
150
+ // conf file name in turn (synapse.conf, then the pre-rename second-brain.conf
151
+ // as a fallback), each resolved through the same tier order.
152
+ function resolveVaultDir() {
153
+ const override = process.env.OBSIDIAN_VAULT_DIR;
154
+ if (override) return override;
155
+
156
+ for (const name of ["synapse.conf", "second-brain.conf"]) {
157
+ const confPath = resolveConfPath(name);
158
+ if (!confPath) continue;
159
+ let text;
160
+ try {
161
+ text = fs.readFileSync(confPath, "utf8");
162
+ } catch {
163
+ continue;
164
+ }
165
+ const raw = confGet(text, "OBSIDIAN_VAULT_DIR");
166
+ if (raw === null) continue;
167
+ const expanded = confExpand(raw);
168
+ if (expanded) return expanded;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ // Same request curl -s --cacert ... --max-time 5 made: any HTTP response
174
+ // (whatever the status) counts as reachable, matching curl's own default of
175
+ // treating a completed request, not a 2xx, as success. Only a TLS/connection
176
+ // failure or the timeout counts as unreachable.
177
+ function endpointReachable(port, certPath) {
178
+ return new Promise((resolve) => {
179
+ let ca;
180
+ try {
181
+ ca = fs.readFileSync(certPath);
182
+ } catch {
183
+ resolve(false);
184
+ return;
185
+ }
186
+ const req = https.request(
187
+ { hostname: "127.0.0.1", port, path: "/", method: "GET", ca, timeout: 5000 },
188
+ (res) => {
189
+ res.resume();
190
+ resolve(true);
191
+ }
192
+ );
193
+ req.on("timeout", () => {
194
+ req.destroy();
195
+ resolve(false);
196
+ });
197
+ req.on("error", () => resolve(false));
198
+ req.end();
199
+ });
200
+ }
201
+
202
+ function updateSettingsCaCert(settingsPath, certPath) {
203
+ let settings = {};
204
+ try {
205
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
206
+ } catch {
207
+ // Missing file -> {} (matches the original always seeding one); invalid
208
+ // JSON in an existing file -> also {}, but written to a temp file and
209
+ // swapped in atomically below, same as jq's own "never touch the
210
+ // original until the replacement is known-good" behavior.
211
+ }
212
+ settings.env = settings.env || {};
213
+ settings.env.NODE_EXTRA_CA_CERTS = certPath;
214
+
215
+ const tmp = path.join(os.tmpdir(), `obsidian-mcp-refresh.${process.pid}.${Date.now()}`);
216
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
217
+ fs.renameSync(tmp, settingsPath);
218
+ }
219
+
220
+ // Registering means removing first -- `claude mcp add` will not overwrite an
221
+ // existing name, and there is no atomic replace.
222
+ function isRegistered() {
223
+ const res = spawnSync("claude", ["mcp", "get", "obsidian"], { stdio: "ignore" });
224
+ return !res.error && res.status === 0;
225
+ }
226
+
227
+ function main() {
228
+ if (!commandExists("claude")) return;
229
+
230
+ const vault = resolveVaultDir();
231
+ if (!vault || !fs.existsSync(vault)) return;
232
+
233
+ const pluginData = path.join(vault, ".obsidian", "plugins", "obsidian-local-rest-api", "data.json");
234
+ if (!fs.existsSync(pluginData)) return;
235
+
236
+ const home = os.homedir();
237
+ const certPath = path.join(home, ".claude", "obsidian-local-rest-api-ca.pem");
238
+
239
+ let data;
240
+ try {
241
+ data = JSON.parse(fs.readFileSync(pluginData, "utf8"));
242
+ } catch {
243
+ return;
244
+ }
245
+
246
+ const apiKey = data.apiKey;
247
+ const port = data.port;
248
+ if (!apiKey || !port) return;
249
+ if (!data.crypto || typeof data.crypto.cert !== "string") return;
250
+
251
+ fs.mkdirSync(path.dirname(certPath), { recursive: true });
252
+ fs.writeFileSync(certPath, data.crypto.cert);
253
+
254
+ endpointReachable(port, certPath).then((endpointOk) => {
255
+ // Written unconditionally, before the registered/reachable check below --
256
+ // the cert path itself doesn't depend on whether the endpoint answers
257
+ // right now, so a session that never gets past the "leave it alone"
258
+ // branch still ends up with NODE_EXTRA_CA_CERTS pointing at the current
259
+ // cert.
260
+ const settingsPath = path.join(home, ".claude", "settings.json");
261
+ try {
262
+ updateSettingsCaCert(settingsPath, certPath);
263
+ } catch {
264
+ // Best-effort, matching the original: a failed settings.json update
265
+ // does not block anything below.
266
+ }
267
+
268
+ const registered = isRegistered();
269
+
270
+ // An existing registration that already works is, by definition, one
271
+ // that worked at some point; replacing it with one pointing at a port
272
+ // that does not currently answer trades something working for nothing,
273
+ // so leave it alone in that case rather than risk it.
274
+ if (!endpointOk && registered) {
275
+ process.stderr.write(
276
+ "synapse: obsidian MCP endpoint unreachable, left the existing registration in place\n"
277
+ );
278
+ return;
279
+ }
280
+
281
+ spawnSync("claude", ["mcp", "remove", "obsidian", "-s", "user"], { stdio: "ignore" });
282
+ spawnSync(
283
+ "claude",
284
+ [
285
+ "mcp",
286
+ "add",
287
+ "--transport",
288
+ "http",
289
+ "obsidian",
290
+ `https://127.0.0.1:${port}/mcp/`,
291
+ "--header",
292
+ `Authorization: Bearer ${apiKey}`,
293
+ "-s",
294
+ "user",
295
+ ],
296
+ { stdio: "ignore" }
297
+ );
298
+ });
299
+ }
300
+
301
+ if (require.main === module) main();
302
+
303
+ module.exports = { confGet, confUnquote, confExpand, resolveConfPath, resolveVaultDir };
@@ -0,0 +1,54 @@
1
+ // Locates the compiled synapse/synapse-hook binaries for the current
2
+ // platform -- shipped as a separate optionalDependency package
3
+ // (@imunitic/synapse-{platform}-{arch}, mirroring esbuild/@rollup's own
4
+ // split), not fetched at install time. npm's own `os`/`cpu` package.json
5
+ // fields make `npm install` skip every platform package but the matching
6
+ // one, and registry shasum verification already covers integrity, so
7
+ // there's no fetch/SHA256SUMS/timeout logic to hand-roll here at all --
8
+ // this module only ever reads what npm already put on disk.
9
+
10
+ "use strict";
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ const CLI_NAME = "synapse";
16
+ const HOOK_NAME = "synapse-hook";
17
+
18
+ // Matches process.platform/process.arch directly (not a Zig target triple)
19
+ // since that's what npm's own `os`/`cpu` fields select against -- the
20
+ // release pipeline maps its Zig target names to this naming once, at
21
+ // publish time, not here.
22
+ function platformPackageName() {
23
+ return `@imunitic/synapse-${process.platform}-${process.arch}`;
24
+ }
25
+
26
+ // Goes through node's own module resolution -- works whether the platform
27
+ // package landed as a normal npm dependency, a workspace symlink, or a
28
+ // `file:` link for local testing, rather than assuming a node_modules
29
+ // layout this shouldn't hardcode.
30
+ function resolveBinDir() {
31
+ try {
32
+ const pkgJsonPath = require.resolve(`${platformPackageName()}/package.json`);
33
+ return path.join(path.dirname(pkgJsonPath), "bin");
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function resolvedPath(name) {
40
+ const binDir = resolveBinDir();
41
+ if (!binDir) return null;
42
+ const file = path.join(binDir, name);
43
+ return fs.existsSync(file) ? file : null;
44
+ }
45
+
46
+ function cliPath() {
47
+ return resolvedPath(CLI_NAME);
48
+ }
49
+
50
+ function hookPath() {
51
+ return resolvedPath(HOOK_NAME);
52
+ }
53
+
54
+ module.exports = { platformPackageName, resolveBinDir, cliPath, hookPath, CLI_NAME, HOOK_NAME };
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@imunitic/synapse",
3
+ "version": "0.0.1-test.0",
4
+ "description": "Memory for Claude Code, Codex CLI, and OpenCode: a durable Obsidian vault plus a per-repo code graph.",
5
+ "bin": {
6
+ "synapse-setup": "bin/synapse-setup.cjs",
7
+ "synapse": "bin/synapse.cjs",
8
+ "synapse-hook": "bin/synapse-hook.cjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "skills",
14
+ "commands",
15
+ "harness",
16
+ "synapse-claude.md",
17
+ "Index.md.template",
18
+ "*.conf.template"
19
+ ],
20
+ "optionalDependencies": {
21
+ "@imunitic/synapse-darwin-arm64": "0.0.1-test.0",
22
+ "@imunitic/synapse-linux-x64": "0.0.1-test.0",
23
+ "@imunitic/synapse-linux-arm64": "0.0.1-test.0"
24
+ },
25
+ "license": "SEE LICENSE IN LICENSE"
26
+ }
@@ -0,0 +1,211 @@
1
+ ---
2
+ name: synapse-node
3
+ description: Tier 2 staleness check and lazy regeneration for a Synapse code-graph node, run whenever a node's body is about to be read and used — not a hook, a procedure Claude follows itself.
4
+ ---
5
+
6
+ # Synapse Node Read: Staleness Check, Regeneration, Unassigned Sweep
7
+
8
+ Built by `/synapse-init`, kept flagged stale at edit time by the `PostToolUse`
9
+ hook (`synapse-hook staleness`, Tier 1). This skill is Tier 2 — the authoritative, lazy check that
10
+ fires only when a node's content is actually about to be consumed, never on a schedule and never
11
+ speculatively.
12
+
13
+ ## When to invoke (proactive — do not wait to be asked)
14
+
15
+ Whenever a `synapse/{project}/{Node}.md` file is about to be read **for its content to actually be
16
+ used** (orienting on a subsystem, answering a question about it, deciding where to make a change)
17
+ — not for a `vault_list`/title-only skim. Run this procedure *before* trusting what comes back from
18
+ that read. This is the one Synapse mechanism that isn't a hook: a hook is compiled code with no
19
+ reasoning, and classifying whether a file "fits" a node is exactly the kind of judgment call that
20
+ needs one.
21
+
22
+ ## Procedure
23
+
24
+ 1. **Verify the whole project once, with the script.** Run `~/.synapse query stale` from
25
+ inside the repo. It prints one `{node title}\t{reason}` line per stale node and nothing at all
26
+ when everything is current, so its output is the complete stale set for the project.
27
+
28
+ Do this **once** per orienting task, not once per node: it costs a single `git hash-object` fork
29
+ plus one GET per node (a couple of seconds for a few dozen nodes), and covers every node at once. Re-run only
30
+ after source files have actually changed since the last run.
31
+
32
+ Never do this by hand instead. Recomputing a digest needs the node's path list, and both places
33
+ it lives are ruinous to read into context — a hub node's own `sources` runs to ~38k tokens and
34
+ the index is binary and tens of megabytes. The script exists so the only thing reaching a context window is the list
35
+ of stale titles.
36
+
37
+ **Exit 1 means "no information", not "clean."** It signals a missing dependency, no vault, no
38
+ namespace for this repo, or a `remote:` mismatch. Do not treat that as a passing verification —
39
+ either fix the cause or proceed knowing the graph is unverified, and say which.
40
+
41
+ 2. **Also honour the Tier 1 flag.** `rg -m1 '^stale:' "$OBSIDIAN_VAULT_DIR/synapse/{project}/{Node}.md"`
42
+ — one line, negligible cost. The two tiers catch different things: Tier 1 flags edits made through
43
+ this Claude Code session the moment they happen, the script catches everything including changes
44
+ the hook never saw (`git pull`, branch switch, rebase, an IDE edit). **Either one saying stale
45
+ means stale.** A node named by neither is fresh — use its content as-is and skip Regeneration and
46
+ the sweep, which only ride along when some regeneration actually occurs.
47
+ 3. **Read the node's body, never the whole file.** `sources` is exhaustive — every file the node
48
+ covers, each with a hash — so a hub node's frontmatter alone can run to ~38k tokens while its
49
+ actual prose is under 1k. Consultation never needs `sources`; the script handles verification.
50
+ So skip the frontmatter entirely:
51
+
52
+ ```sh
53
+ ~/.synapse query body "{Node title}"
54
+ ```
55
+
56
+ That prints only what is between the generated fences — so it excludes `## Notes` as well as the
57
+ frontmatter, which a raw offset read would not — and costs ~500 tokens whether the node covers 5
58
+ files or 941. **A full `mcp__obsidian__vault_read`
59
+ of a hub node is a mistake, not merely expensive** — it spends tens of thousands of tokens on a
60
+ path list you are not going to use. Use `vault_read` only when you specifically need the
61
+ frontmatter or the links/backlinks metadata it returns.
62
+
63
+ Finding *which* node to read is a separate job, and search does it: because `sources` is
64
+ exhaustive, `mcp__obsidian__search_simple` on a class or file name locates the owning node even
65
+ when that name appears nowhere in any node's prose, and returns snippets rather than whole files.
66
+
67
+ For the other questions about a node, use the same tool rather than reading frontmatter:
68
+ `synapse query sources "{Node}" --count|--modules|--filter <p>` for what it covers, and
69
+ `synapse query field "{Node}" <key>` for a single scalar such as `stale` or `built_at`.
70
+
71
+ 4. **Regeneration (only if step 1 or 2 found the node stale).** You re-author the prose; a script
72
+ writes the file. **The node contract itself — frontmatter fields, the crux pointer, `## Links`,
73
+ `grounded_in` — is the `synapse-node-format` skill**, shared with `/synapse-init` and
74
+ `/synapse-rebuild`. What follows here is only what differs when *re*-authoring an existing
75
+ node rather than writing a new one. Everything mechanical — hashes, `sources_digest`, the `## Sources` mirror,
76
+ `built_at`, `commit`, `stale: false`, and preserving `## Notes` — belongs to
77
+ `synapse write-node`, because a hub node's `sources` can no more be *emitted* into a tool call
78
+ than read into a window. Let `$W` be the project's work directory,
79
+ `~/.claude/synapse-work/{repo}@{branch}/`.
80
+
81
+ - **Get the node's path list into a file, never into context:**
82
+
83
+ ```sh
84
+ ~/.synapse query sources "{Node title}" > "$W/paths.txt"
85
+ ```
86
+
87
+ If `$W/manifest.tsv` exists (or the namespace has `_manifest.tsv`), prefer re-running
88
+ `~/.synapse build-lists` instead and use the regenerated `lists/NN.txt`: it
89
+ re-derives every list from the clustering patterns, so files *added* since the last build are
90
+ picked up automatically rather than sitting in `_unassigned`. `synapse query sources` can only
91
+ return what the node already claims.
92
+ - Consult `synapse/{project}/_profile.txt` if it exists — the aggregations that proved useful for
93
+ this repo, and the negative results (searches that came back empty) worth not re-deriving.
94
+ - **Prefer patching the prose from the diff over re-reading the node's sources.** If the node has a
95
+ `commit` and only a small fraction of its files changed, read the current prose
96
+ (`synapse query body`), get `git diff --name-status -M <commit>..HEAD` for its paths, and read
97
+ hunks only for a bounded selection — always including `crux_path`, the file the crux was cut from.
98
+ Amend the sentences the diff contradicts and keep the rest verbatim. A node covering 15,000 files
99
+ where 12
100
+ changed already has prose encoding the other 14,988, and re-reading them all both costs enormously
101
+ and discards findings the diff has nothing to say about. Project the diff as carefully as
102
+ `sources`: names first, `--stat` to size it, hunks only for the selection.
103
+ - Fall back to reading the files when patching cannot be justified — a large fraction changed, the
104
+ `crux` file is gone, or the baseline is unusable. Then try `~/.synapse tags {path}`
105
+ first (exit 0 use the tags, exit 1 fall back to reading the file, exit 2 run the discovery
106
+ procedure `/synapse-init` documents, then retry), and read the load-bearing files in full — the
107
+ tags signal informs regrouping, it never substitutes for reading a file before rewriting its prose.
108
+ If this is happening across many nodes at once, stop and run `/synapse-rebuild` instead: that is
109
+ the instrument for major drift, and it triages node by node rather than paying full cost for each.
110
+ - Re-author `## Summary`, `## Crux` and `## Links` to match what the files contain now, into
111
+ `$W/body.md`. Re-check the node's one-line `summary` as well; keep the existing one with
112
+ `synapse query field "{Node title}" summary` if it still fits.
113
+ - **`## Crux` goes back as a directive, never as the code you just read.** `synapse query body`
114
+ returns the *expanded* crux — the fenced block the writer sliced last time — so copying it forward
115
+ stores a quote of an older version of the file as though it were current. Emit
116
+ `<!-- crux: <path> <start>-<end> -->` and let the writer cut it out again. Reuse the recorded
117
+ pointer (`synapse query field "{Node}" crux_path` and `crux_lines`) when that file did not
118
+ change; pick a fresh span when it did, because the old line numbers may now land somewhere else.
119
+ `<!-- crux: none -->` if nothing focal remains — and a node with no `crux_path` had none already.
120
+ - **Re-emit the groundings, or they are lost.** `grounded_in` lives in frontmatter and the writer
121
+ strips its directives from the body, so a recovered body carries none of them: writing it back
122
+ without re-emitting drops the node's whole provenance silently. Recover the pointers with
123
+ `synapse query grounding "{Node}" --list` (prints `path<TAB>lines`) and put a
124
+ `<!-- grounded_in: <path> <lines> -->` back for each. Run `synapse query grounding` first: a
125
+ `moved` line gives you the corrected range to use, and a `changed` line marks evidence that no
126
+ longer says what the summary claims — re-point that one, and fix the sentence resting on it.
127
+ - **Write it back with the script:**
128
+
129
+ ```sh
130
+ ~/.synapse write-node --title "{Node title}" --summary "{one line}" \
131
+ --paths "$W/paths.txt" --body "$W/body.md"
132
+ ```
133
+
134
+ It replaces only the generated region and re-emits everything after the closing fence verbatim,
135
+ which is what makes the `## Notes` guarantee enforceable rather than a promise.
136
+ - **Never hand-write the frontmatter**, with `vault_patch` at `targetType: frontmatter` or
137
+ otherwise. Two reasons, both load-bearing: that patch re-serialises the whole YAML block and
138
+ YAML-coerces values (an all-digit `hash` becomes `1.1111111111111112e+39`), and
139
+ enumerating fields by hand is how `summary` and `commit` get silently dropped — which then breaks
140
+ the next `synapse build-project-index` run, far from the cause.
141
+ - **`## Notes` is human-authored only.** Never write into it — not at regeneration, not to record
142
+ what you just did. (Task notes in `tasks/` are a different artifact: the `synapse-task` skill *does*
143
+ append there. Do not carry that habit into a Synapse node.)
144
+ - If the node's `summary` or title changed, rebuild the index so the map matches:
145
+ `~/.synapse build-project-index`.
146
+ - **Say out loud that a regeneration happened** — e.g. "Node '{title}' was stale, regenerated
147
+ before use." This has real latency and token cost, unlike Tier 1/2's detection; it must never
148
+ be absorbed silently into the read.
149
+ 5. **Unassigned sweep (rides along on step 4, whenever any regeneration fires for this project):**
150
+ - Read the bucket with a shell command, not into context — the index runs to tens of megabytes:
151
+
152
+ ```sh
153
+ ~/.synapse index unassigned
154
+ ```
155
+
156
+ Empty → nothing to do, skip silently (an empty sweep isn't worth announcing).
157
+ - Otherwise read `synapse/{project}/Index.md` for the current node list (titles + summaries).
158
+ - Tag the whole bucket in **one** call — write the paths to a list and run
159
+ `~/.synapse tags --paths {list}`, whose output is attributable (an unindented
160
+ line is a path, the tab-indented lines under it are its tags). A per-file loop costs ~33× more
161
+ for the same answer. Fall back to a full read for ambiguous cases, then classify against that
162
+ node list. **The judgment is which cluster a path belongs to; the bookkeeping is not yours to
163
+ do:**
164
+ - **Fits an existing node** → widen that node's line in `$W/manifest.tsv` so the pattern claims
165
+ it, then re-run `synapse build-lists`. Set that node's `stale: true` for its own next read
166
+ rather than regenerating it now — only the node that triggered step 4 is regenerated
167
+ immediately. If there is no manifest, add the path to that node's list file instead.
168
+ - **Fits nothing** → leave it unassigned. A genuinely new subsystem wants its own manifest line
169
+ and its own node, which is `/synapse-init` work, not a sweep.
170
+ - Then rebuild the projection with `~/.synapse build-index`. **Never hand-edit
171
+ `_index.bin`** — it is derived, binary, and tens of megabytes; there is nothing to
172
+ hand-edit.
173
+ - **Announce every outcome**, same transparency rule as regeneration: which file, and which
174
+ node it was attached to (or that it's still unassigned).
175
+ - Sweep the **whole** bucket unconditionally, not just entries related to the node that
176
+ triggered step 4 — an unrelated new subsystem rides along on any regeneration event
177
+ happening anywhere in the project, by design (see the design note's Node Granularity &
178
+ Grouping section).
179
+
180
+ ## Guardrails
181
+
182
+ - `stale` is the right check for *this* procedure, but know what it cannot tell you: a file in no
183
+ node's `sources` is invisible to it, and a renamed file reads as "gone". `synapse query drift`
184
+ answers both, by diffing each node's recorded `commit` against HEAD. It is not on this hot path on
185
+ purpose — reading one node's body should not pay for a repo-wide coverage audit — so reach for it
186
+ when orienting after a `git pull` or a branch switch, and leave the systematic case to a deliberate
187
+ refresh rather than a read.
188
+ - Never skip `synapse query stale` just because `stale: false` looked plausible — Tier 1 only catches
189
+ edits made through this Claude Code session; a `git pull`, branch switch, or externally-made
190
+ edit is invisible to it and only the script catches those.
191
+ - Never hand-roll the verification by reading `sources` or the index — that is the whole reason
192
+ the script exists, and doing it manually costs tens to hundreds of thousands of tokens.
193
+ - Never hand-write a node's frontmatter or `## Sources` mirror. `synapse write-node` owns them, and
194
+ writing them by hand both cannot scale to a hub node and silently drops `summary`/`commit`.
195
+ - Never treat the script's exit 1 as a clean result. It means the check could not run.
196
+ - Never regenerate a node that neither the script nor its `stale:` flag named — regeneration is real
197
+ cost, reserved for actual staleness.
198
+ - Never `vault_read` a hub node just to read its summary. Offset past the frontmatter (step 3).
199
+ - Never silently fold a regeneration or an unassigned-file attachment into normal output — both
200
+ get an explicit, visible announcement line.
201
+ - `## Notes` content is sacrosanct across regeneration — if a rewrite would touch it, that's a bug
202
+ in the regeneration step, not an acceptable side effect.
203
+
204
+ ## Fallback if regeneration proves disruptive
205
+
206
+ If lazy per-read regeneration turns out to be too disruptive in practice (e.g. a single task
207
+ orienting against several stale nodes at once, each paying a regeneration cost), the documented
208
+ fallback (see the design note's Alternatives) is to downgrade staleness to a plain cache miss:
209
+ skip the stale node's content entirely and read its underlying source files directly instead,
210
+ leaving regeneration to a manual step. This is a deliberate escape hatch, not the default — only
211
+ switch to it if the default is causing real friction, and say so if you do.