@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.
- package/Index.md.template +23 -0
- package/bin/synapse-hook.cjs +19 -0
- package/bin/synapse-setup.cjs +420 -0
- package/bin/synapse.cjs +20 -0
- package/commands/synapse-design-note.md +229 -0
- package/commands/synapse-init.md +354 -0
- package/commands/synapse-note.md +196 -0
- package/commands/synapse-rebuild-diff.md +314 -0
- package/commands/synapse-rebuild-full.md +152 -0
- package/commands/synapse-status.md +144 -0
- package/commands/synapse-task-note.md +133 -0
- package/commands/synapse-vault-tidy.md +187 -0
- package/harness/claude/hooks.json +54 -0
- package/harness/codex/hooks.json +54 -0
- package/harness/codex/skills/synapse-design-note/SKILL.md +236 -0
- package/harness/codex/skills/synapse-init/SKILL.md +354 -0
- package/harness/codex/skills/synapse-note/SKILL.md +212 -0
- package/harness/codex/skills/synapse-rebuild-diff/SKILL.md +315 -0
- package/harness/codex/skills/synapse-rebuild-full/SKILL.md +149 -0
- package/harness/codex/skills/synapse-status/SKILL.md +146 -0
- package/harness/codex/skills/synapse-task-note/SKILL.md +133 -0
- package/harness/codex/skills/synapse-vault-tidy/SKILL.md +187 -0
- package/harness/opencode/plugin/synapse.js +164 -0
- package/lib/obsidian-mcp-refresh.cjs +303 -0
- package/lib/resolve-binaries.cjs +54 -0
- package/package.json +26 -0
- package/skills/synapse-node/SKILL.md +211 -0
- package/skills/synapse-node-authoring/SKILL.md +188 -0
- package/skills/synapse-node-format/SKILL.md +205 -0
- package/skills/synapse-orientation/SKILL.md +468 -0
- package/skills/synapse-query/SKILL.md +99 -0
- package/skills/synapse-task/SKILL.md +261 -0
- package/skills/synapse-vault/SKILL.md +107 -0
- package/synapse-claude.md +220 -0
- package/synapse-fence-languages.conf.template +24 -0
- package/synapse-ignore-files.conf.template +45 -0
- package/synapse-module-boilerplate.conf.template +24 -0
- package/synapse-projects.conf.template +14 -0
- package/synapse-prompt-stopwords.conf.template +594 -0
- 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.
|