n8n-decanter 0.10.1 → 0.10.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.2] - 2026-08-18
11
+
12
+ ### Changed
13
+
14
+ - **"Missing `n8n-instance` tools? Restart" is no longer the only answer we
15
+ give — in a nested sync dir it was a dead end.** Agent wiring loads at
16
+ startup *from the dir the agent was started in*, so tools declared in
17
+ `.mcp.json` can be absent for two reasons: the wiring is new (a restart fixes
18
+ it), or the wiring sits **below** the launch dir, where no restart will ever
19
+ load it. Every surface that taught only the first now teaches both, with the
20
+ discriminator you can apply yourself — *is that `.mcp.json` below the
21
+ directory you started the agent in?* — and the two working routes (start the
22
+ agent in the sync dir, or wire the root with `N8N_DECANTER_DIR` plus a
23
+ root-resolvable command): the scaffolded `AGENTS.md`, `init`'s own output
24
+ (which now prints the nested guidance **instead of** the restart line when it
25
+ scaffolds into a nested dir), [Working with coding
26
+ agents](/docs/agents/overview/), [init](/docs/cli/init/) and a new
27
+ [troubleshooting](/docs/faq/troubleshooting/) entry. The CLI never needed
28
+ that wiring, so `pull`/`push`/`preflight` keep working either way — the docs
29
+ now say so too.
30
+
31
+ - **The oversized-scenario warning now names a remedy you can actually take.**
32
+ Above 1 MB `scenario create` said "Trim it before that" — but there is no
33
+ trim flag, so the advice pointed nowhere. It now says what is true and what
34
+ to do: nothing is committed yet (`scenario create` never commits — only the
35
+ next `pull`/`push` sweeps the folder into history), so you can still cut
36
+ items out of `data.resultData.runData` by hand, or re-create the scenario
37
+ with `--scaffold` instead of `--execution` and author the pins yourself.
38
+ `docs/cli/scenario.md` spells out both, including which parts of a capture
39
+ are unsafe to delete.
40
+
41
+ - **The agent contract now says to orient *before* the first edit, not only
42
+ before the push.** `preflight` was always the read-only report of the
43
+ instance's side (`drift`, `CONFLICT`, a pending `parity`), but every surface
44
+ framed it as the pre-push gate — so an agent edited first and learned about a
45
+ colleague's UI edit afterwards. The scaffolded `AGENTS.md` and both agent doc
46
+ pages now open the loop with it: on drift, `pull` and carry on; on a
47
+ `CONFLICT`, show `diff` and ask before either side is overwritten.
48
+ - **The scaffold stopped advertising a deny rule it no longer has.** The
49
+ Claude Code `settings.json` denies `.decanter.json`, `.env` and
50
+ `push --force` — but the scaffolded `CLAUDE.md` and `opencode.json` still
51
+ claimed `*.remote.js` was blocked too. Those conflict artifacts were removed
52
+ in the MCP pivot; both files now describe the policy that actually ships.
53
+ - **Docs: `mcp connect` / `mcp serve` no longer read as if the guard obtained
54
+ credentials itself.** "decanter's own credentials", "the agent never holds an
55
+ n8n credential" and "no secret to manage" led readers (and agents) to believe
56
+ the guard handles the n8n login. It does not: it only *reads* what `init`
57
+ wrote to `.env` / `.decanter-auth.json`, and can at most refresh an OAuth
58
+ token. The mcp-connect / mcp-serve pages and the template `AGENTS.md` now say
59
+ so outright — obtaining credentials is exclusively `init`'s job, and a
60
+ "no MCP credentials" answer means run `init`, not retry.
61
+
62
+ - **"decanter.config.json not found" now points at `init` — with its flags.**
63
+ The classic half-setup is a hand-written `.env`: an agent that cannot run the
64
+ browser OAuth flow asks its human to paste `N8N_MCP_TOKEN` into a file and
65
+ stops there, leaving no config, template, `.gitignore` or agent wiring behind.
66
+ The error now says the dir is not a sync dir yet, that `.env` alone is not
67
+ enough, and prints the prompt-free command that fixes it
68
+ (`n8n-decanter init . --host <host-url> --token <mcp-token>`). The docs
69
+ (init, configuration, troubleshooting, README) say the same thing: headless is
70
+ not a reason to skip `init` — it takes the same token as a flag.
71
+
72
+ - **`init`'s restart reminder now covers everything it wires, not just
73
+ permission rules.** MCP servers (`.mcp.json` / `opencode.json` — including the
74
+ guarded `n8n-instance` server), permission rules and hooks are all read at
75
+ agent **startup**, and `init` normally runs inside the session it configures.
76
+ The reminder now fires when any of those files is newly scaffolded and says
77
+ what it means: this session is still unconfigured, restart the agent (or
78
+ `/reload`). README, init and the agents docs say the same, and the scaffolded
79
+ `AGENTS.md` tells the agent to **ask for a restart** when the `n8n-instance`
80
+ tools are missing instead of connecting to the instance directly.
81
+
82
+ ### Fixed
83
+
84
+ - **The scaffolded hooks now work when your sync dir is not where the agent was
85
+ started.** All three found the sync dir by assuming it was the current
86
+ directory, which only holds for an agent launched inside it. With the sync dir
87
+ nested in a bigger repo — a layout the docs explicitly allow — the agent runs
88
+ at the repo root and every one of them misbehaved: the rename-reference guard
89
+ became a **silent no-op**, so `$('Old Name')` references left behind by a
90
+ `renameNode` went unreported until a later `push` refused them; the verify hook
91
+ spawned the CLI without a directory, so it **blocked every node-file edit** with
92
+ a "not a sync dir" error; and the MCP routing check scanned the wrong tree.
93
+ Each hook now locates the sync dir from its own installed path, so it behaves
94
+ the same wherever the agent starts.
95
+ - **The routing check no longer misses direct-route servers in your user
96
+ config.** Its lookup for this project's entry in `~/.claude.json` matched the
97
+ current directory, but that file is keyed by the repository root — so in any
98
+ sync dir inside a git repo the check silently found nothing. It now matches the
99
+ project entry for the sync dir or any parent of it.
100
+ - **The verify hook finds a locally installed CLI.** It only ever looked for
101
+ `n8n-decanter` on `PATH`, so with a local (non-global) install it stayed quiet
102
+ and no verification ran at all. It now prefers the sync dir's
103
+ `node_modules/.bin` and falls back to `PATH`.
104
+ - **"decanter.config.json not found" no longer sends you to `init` when the sync
105
+ dir is simply somewhere else.** Run from *above* a perfectly good sync dir —
106
+ what happens whenever an agent starts at the repo root — the error read as if
107
+ nothing had ever been set up, and advised scaffolding a second sync dir on top
108
+ of the working one. It now looks below the directory it searched from, names
109
+ the sync dir it finds there, and prints the `--dir` / `N8N_DECANTER_DIR` form
110
+ that reaches it. When there really is no sync dir, the `init` advice is
111
+ unchanged.
112
+
113
+ ### Added
114
+
115
+ - **The routing check also looks at parent directories, up to your repository
116
+ root.** Agents merge `.mcp.json` from every directory above the one they start
117
+ in, so a server pointing straight at your n8n instance can sit in the repo
118
+ root's config and still route this session. The scan stops at the repository
119
+ boundary, so it never reaches into unrelated parent directories, and an
120
+ offender found above the sync dir is named by its relative path
121
+ (`../.mcp.json`) so you can tell which file it means.
122
+
123
+ - **`--dir <path>` (or `N8N_DECANTER_DIR`) points any verb at a sync dir that is
124
+ not the current directory.** The layout the docs allow but nothing supported:
125
+ the sync dir nested inside a bigger repo, with the agent started at the repo
126
+ root. The MCP entry `init` writes into the sync dir is invisible from up
127
+ there, and hoisting it to the repo root spawned the guard where no
128
+ `decanter.config.json` could be found — so the guard did not work at all. The
129
+ search still only walks *up*; this says where it starts. In an agent's server
130
+ entry the environment variable is the form to reach for
131
+ (`"env": { "N8N_DECANTER_DIR": "flows" }`), and relative values resolve
132
+ against the working directory, so a repo-relative one keeps working for
133
+ everyone who clones. `init` does not take `--dir` — it still takes the
134
+ directory to scaffold as an argument.
135
+
136
+ - **`init` now tells you how to wire an agent when your sync dir is nested in a
137
+ bigger project.** Agents look for `.mcp.json`, `opencode.json` and
138
+ `.claude/settings.json` from the directory they were *started* in and never in
139
+ one below it, so everything `init` scaffolds is inert for an agent started at
140
+ the repo root above the sync dir. When init sees a project around it (a `.git` or
141
+ `package.json` in a parent) it prints both shapes that work: starting the
142
+ agent inside the sync dir — recommended, nothing further to configure — or the
143
+ paste-ready MCP, opencode and hooks/permissions blocks for the project root,
144
+ with every path and glob already prefixed. That prefixing is the point: copied
145
+ up verbatim, `Read(.env)` / `Edit(.env)` guard the *root's* `.env` and quietly
146
+ stop protecting your credentials. `init` prints this; it never writes into a
147
+ parent directory. Only on the run that first scaffolds the agent files, and
148
+ never for a standalone sync dir.
149
+
10
150
  ## [0.10.1] - 2026-08-16
11
151
 
12
152
  ### Added
package/README.md CHANGED
@@ -73,6 +73,13 @@ n8n-decanter init [dir] # OAuth in your browser (or a pasted MCP token)
73
73
  n8n-decanter pull # pick a workflow from the list → it pulls
74
74
  ```
75
75
 
76
+ **Restart your coding agent after `init`** — the MCP servers, permission rules
77
+ and hooks it scaffolds are read at agent startup, so the session that ran `init`
78
+ is still unconfigured. If the sync dir is nested in a bigger repo and you start
79
+ the agent at the repo root, a restart is *not* enough — that wiring loads only
80
+ from the dir the agent starts in
81
+ ([which file loads from where](docs/agents/overview.md#where-the-agent-wiring-loads-from)).
82
+
76
83
  On a terminal, `pull` with no argument lists your n8n workflows — pick one and
77
84
  it lands in `workflows/<slug>/`. Know the id already? `n8n-decanter pull
78
85
  <id-or-name>` pulls it directly (scriptable, no TTY needed).
@@ -80,7 +87,9 @@ it lands in `workflows/<slug>/`. Know the id already? `n8n-decanter pull
80
87
  set a bare `pull`/`push`/`diff` acts on.)
81
88
 
82
89
  **Credentials:** OAuth by default (via `init`); `N8N_MCP_TOKEN` for
83
- headless/CI; `N8N_API_KEY` is optional, needed only for `executions`,
90
+ headless/CI — pass it to `init --host <url> --token <mcp-token>` rather than
91
+ hand-writing `.env`, which leaves the config, template, `.gitignore` and agent
92
+ configs unscaffolded; `N8N_API_KEY` is optional, needed only for `executions`,
84
93
  `data-tables`, and `backup`. Details: [Installation](docs/getting-started/installation.md),
85
94
  [init](docs/cli/init.md), [Configuration](docs/concepts/configuration.md).
86
95
 
@@ -132,6 +141,17 @@ renaming, and archiving workflows — and adding or renaming nodes — are
132
141
  **n8n's acts**: do them in the editor or let your agent do it over n8n's MCP
133
142
  tools (through the guard above); the next `pull` reconciles the local mirror.
134
143
 
144
+ **Sync dir nested in a bigger repo?** A global `--dir <path>` (or
145
+ `N8N_DECANTER_DIR`) starts the upward `decanter.config.json` search there
146
+ instead of in the working directory — every verb honours it except `init`,
147
+ which takes the directory to scaffold as its argument. A coding agent launched
148
+ at the repo root needs it too — agents look for `.mcp.json` and
149
+ `.claude/settings.json` from where they were *started*, never in a directory
150
+ below it, so either start the agent in the sync dir or wire the root
151
+ explicitly:
152
+ [Working with coding agents](docs/agents/overview.md#where-the-agent-wiring-loads-from),
153
+ [mcp connect](docs/cli/mcp-connect.md#when-your-sync-dir-is-not-your-project-root).
154
+
135
155
  ## How it compares
136
156
 
137
157
  n8n-decanter is **Code-node-first**: it optimizes writing, typing, verifying and
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  /**
4
4
  * The one message every cold start hits — a fresh clone has no `.env`, so this
@@ -44,12 +44,114 @@ export function loadEnv(dir) {
44
44
  process.env[key] = value;
45
45
  }
46
46
  }
47
+ /**
48
+ * Where `loadConfig`'s upward search STARTS: `--dir` > `N8N_DECANTER_DIR` > cwd.
49
+ * The search itself is untouched (Plan 81) — this only moves its origin, so a
50
+ * sync dir nested in a bigger repo stays reachable from a process started at the
51
+ * repo root. That is the shape agent wiring falls into: an MCP entry hoisted to
52
+ * the repo root spawns `mcp connect` with cwd = the root, and an upward-only
53
+ * search can never walk *down* into `flows/`.
54
+ *
55
+ * **The env var is the load-bearing half, not the flag** — every agent's MCP
56
+ * server entry has an `env` block, while a `cwd` key is not guaranteed across
57
+ * agents and versions.
58
+ *
59
+ * **Both forms resolve against cwd on purpose:** a committed root `.mcp.json`
60
+ * carrying `N8N_DECANTER_DIR=flows` survives a clone on a teammate's machine,
61
+ * where an absolute `/home/me/repo/flows` would not. (`loadEnv` never overrides
62
+ * an already-set variable, so the sync dir's own `.env` can't fight the value
63
+ * that pointed decanter at it — and couldn't anyway: it is read only *after*
64
+ * the dir has been found.)
65
+ *
66
+ * A path that is not a directory fails here, loudly. Left alone it would walk
67
+ * to the filesystem root and surface as "not found (searched from … upward)" —
68
+ * a message about the wrong problem.
69
+ */
70
+ export function resolveSearchStart(dirFlag, cwd = process.cwd(), env = process.env) {
71
+ const envDir = env.N8N_DECANTER_DIR;
72
+ // Empty counts as unset: an agent config that interpolates a missing value
73
+ // ships `"N8N_DECANTER_DIR": ""`, and inheriting cwd beats failing on it.
74
+ const override = dirFlag !== undefined && dirFlag !== ""
75
+ ? { value: dirFlag, source: "--dir" }
76
+ : envDir !== undefined && envDir !== ""
77
+ ? { value: envDir, source: "N8N_DECANTER_DIR" }
78
+ : undefined;
79
+ if (override === undefined)
80
+ return path.resolve(cwd);
81
+ const resolved = path.resolve(cwd, override.value);
82
+ const stat = statSync(resolved, { throwIfNoEntry: false });
83
+ if (stat === undefined || !stat.isDirectory()) {
84
+ throw new Error(`${override.source} ${override.value} is not a directory (resolved to ${resolved})\n` +
85
+ " it names the sync dir — the folder holding decanter.config.json — and relative paths\n" +
86
+ " resolve against the working directory, so a repo-relative value stays portable");
87
+ }
88
+ return resolved;
89
+ }
90
+ /**
91
+ * Bounded hunt for a sync dir *below* `start` — the evidence that separates
92
+ * "not a sync dir yet" from "the search started too high" (Plan 81). Breadth-
93
+ * first so the shallow hit wins, skipping `node_modules` and every dot-dir
94
+ * (`.git` included — nobody's sync dir lives there, and they are where the file
95
+ * count explodes). Symlinked dirs report `isDirectory()` false from a `Dirent`,
96
+ * so a link cycle is never entered.
97
+ *
98
+ * **Three caps, and the WALL-CLOCK one is the load-bearing cap.** Counting
99
+ * directories is not enough: measured here, a single `existsSync` inside an
100
+ * rclone/NFS-style mount costs ~500 ms, so a scan of a home directory holding
101
+ * one ran **16 seconds** — on the failure path of every verb, the guard's
102
+ * `mcp connect` startup included. This whole scan only sharpens an error
103
+ * message, so it is never worth waiting for: past the deadline it gives up and
104
+ * the caller falls back to the cold-start advice.
105
+ *
106
+ * One `readdirSync` per visited dir, and the hit is read out of those entries —
107
+ * the earlier shape (readdir the parent, then stat `<child>/decanter.config.json`
108
+ * for every child) paid two round trips per directory to learn the same thing.
109
+ */
110
+ function findConfigBelow(start, maxDepth = 3, maxDirs = 400, maxMs = 250) {
111
+ const deadline = Date.now() + maxMs;
112
+ const queue = [{ dir: start, depth: 0 }];
113
+ let visited = 0;
114
+ while (queue.length > 0) {
115
+ if (visited >= maxDirs || Date.now() > deadline)
116
+ return undefined;
117
+ const { dir, depth } = queue.shift();
118
+ visited++;
119
+ let entries;
120
+ try {
121
+ entries = readdirSync(dir, { withFileTypes: true });
122
+ }
123
+ catch {
124
+ // unreadable directory (permissions, a race) — skip it; this scan is
125
+ // never authoritative
126
+ continue;
127
+ }
128
+ // `start` itself cannot hold one — loadConfig checked it before walking up.
129
+ // `!isDirectory()` rather than `isFile()`: a `Dirent` reports a SYMLINK as
130
+ // neither, and loadConfig's own `existsSync` would happily follow one.
131
+ if (dir !== start && entries.some((e) => e.name === "decanter.config.json" && !e.isDirectory()))
132
+ return dir;
133
+ if (depth >= maxDepth)
134
+ continue;
135
+ for (const entry of entries) {
136
+ // Cap the QUEUE too, or one directory with 100k children buys itself
137
+ // 100k queue entries before the visit counter ever gets a say.
138
+ if (queue.length >= maxDirs)
139
+ break;
140
+ if (!entry.isDirectory() || entry.name === "node_modules" || entry.name.startsWith("."))
141
+ continue;
142
+ queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
143
+ }
144
+ }
145
+ return undefined;
146
+ }
47
147
  /**
48
148
  * Load decanter.config.json from cwd (or nearest ancestor) and resolve paths.
49
149
  * `requireHost` gates only N8N_HOST (online verbs need it): the API key is
50
150
  * optional since Plan 32 (MCP is the sync backend; `requireApiKey` guards the
51
151
  * REST-API-only verbs at use time) and MCP credentials are resolved separately
52
152
  * (lib/mcp.mts `resolveMcpAuth` — env token or .decanter-auth.json).
153
+ * `cwd` is where the search *starts*; the CLI passes `resolveSearchStart(…)` so
154
+ * `--dir`/`N8N_DECANTER_DIR` can move that origin without touching the walk.
53
155
  */
54
156
  export function loadConfig(cwd = process.cwd(), { requireHost = true } = {}) {
55
157
  let dir = path.resolve(cwd);
@@ -86,8 +188,34 @@ export function loadConfig(cwd = process.cwd(), { requireHost = true } = {}) {
86
188
  };
87
189
  }
88
190
  const parent = path.dirname(dir);
89
- if (parent === dir)
90
- throw new Error("decanter.config.json not found (searched from " + cwd + " upward)");
191
+ if (parent === dir) {
192
+ // Two different situations end up here, and the advice inverts between
193
+ // them — so say which one the user is in. A sync dir sitting *below* the
194
+ // starting point means the setup is fine and the search merely began too
195
+ // high (an agent launched at the repo root; Plan 81). Sending that user to
196
+ // `init` would scaffold a second sync dir on top of a working one.
197
+ const nested = findConfigBelow(path.resolve(cwd));
198
+ if (nested !== undefined) {
199
+ const rel = path.relative(path.resolve(cwd), nested) || ".";
200
+ throw new Error("decanter.config.json not found (searched from " + cwd + " upward)\n" +
201
+ " it is not missing — the sync dir sits BELOW the working directory: " + nested + "\n" +
202
+ " the search only walks up, so name the sync dir explicitly:\n" +
203
+ // The `=` form, not `--dir <rel>`: the space form declines to eat a
204
+ // value that is also a verb, so a sync dir called `test/` or `list/`
205
+ // would come back as "--dir needs a value" from a copy-paste.
206
+ " n8n-decanter <verb> --dir=" + rel + "\n" +
207
+ " or set N8N_DECANTER_DIR=" + rel + " (agents: the `env` block of the decanter MCP server entry)");
208
+ }
209
+ // A hand-written `.env` is the classic half-setup: an agent that cannot
210
+ // run the browser OAuth flow asks its human to paste `N8N_MCP_TOKEN`
211
+ // into a file and stops there, leaving no config, template, .gitignore
212
+ // or agent wiring. Name the non-interactive `init` (it takes the very
213
+ // same token as a flag) so the fix is one command, not a research task.
214
+ throw new Error("decanter.config.json not found (searched from " + cwd + " upward)\n" +
215
+ " this is not a decanter sync dir yet — writing .env by hand is not enough; init also\n" +
216
+ " scaffolds the config, template, .gitignore and agent configs:\n" +
217
+ " n8n-decanter init . --host <host-url> --token <mcp-token>");
218
+ }
91
219
  dir = parent;
92
220
  }
93
221
  }
package/dist/lib/init.mjs CHANGED
@@ -212,6 +212,156 @@ function migrateRenamedTemplateFiles(srcDir, destDir, manifest, force, log) {
212
212
  }
213
213
  return { skip, keep };
214
214
  }
215
+ /**
216
+ * Nearest STRICT ancestor of `dir` that looks like the **agent's** project root
217
+ * — one holding `.git` (a *file* in worktrees and submodules, so `existsSync`,
218
+ * never `statSync().isDirectory()`) or a `package.json`. Null when there is
219
+ * none, which is the standalone sync dir every earlier version assumed.
220
+ *
221
+ * Only strict ancestors count, and that is what makes the test cheap: the sync
222
+ * dir's own scaffolded `package.json` — and a `git init` run inside it, the
223
+ * shape the docs teach — are never mistaken for a parent project.
224
+ *
225
+ * `.git` beats an intermediate `package.json` on the way up: in a monorepo the
226
+ * repo root is the dir people actually open, while a sub-package manifest is
227
+ * just a stop along the walk.
228
+ *
229
+ * Consulting git here answers "where would the agent be started?", a different
230
+ * question from "where is the sync dir?" — so it is **not** a reversal of
231
+ * `docs/concepts/sync-layout.md`'s "decanter never uses git to find the sync
232
+ * dir" (which remains true: that is still the upward `decanter.config.json`
233
+ * search, git-blind).
234
+ */
235
+ export function projectRootAbove(dir) {
236
+ let current = path.resolve(dir);
237
+ let pkgRoot = null;
238
+ for (;;) {
239
+ const parent = path.dirname(current);
240
+ if (parent === current)
241
+ return pkgRoot; // fs root: only a package.json ever matched
242
+ if (existsSync(path.join(parent, ".git")))
243
+ return parent;
244
+ if (pkgRoot === null && existsSync(path.join(parent, "package.json")))
245
+ pkgRoot = parent;
246
+ current = parent;
247
+ }
248
+ }
249
+ /**
250
+ * The nested-sync-dir note (Plan 81), printed verbatim rather than pointed at:
251
+ * the wiring `init` just wrote is loaded **only from the dir the agent starts
252
+ * in** — `.mcp.json` is merged from every ancestor of that dir (so a nested one
253
+ * is invisible from above) and `.claude/settings.json` is read from that dir
254
+ * alone, no walk in either direction. Verified against Claude Code 2.1.234.
255
+ *
256
+ * Option A leads because it is the only shape with zero further config *and*
257
+ * the only one in which the scaffolded permission globs stay anchored where
258
+ * they were written. Option B is deliberately the long half: a root-hoisted MCP
259
+ * entry needs BOTH the `N8N_DECANTER_DIR` pin (decanter's config search only
260
+ * ever walks *up* from its cwd) and a command that resolves from the root —
261
+ * which `npx --no-install n8n-decanter` does not: it looks in the *launch*
262
+ * dir's `node_modules/.bin`, which under a local install is the sync dir
263
+ * itself. And a hoisted permission list is worse than none: relative globs
264
+ * re-anchor at the root, so a verbatim `Read(.env)`/`Edit(.env)` stops covering
265
+ * the credentials file — hence every glob below carries the sync-dir prefix.
266
+ */
267
+ export function nestedWiringNote(syncDir, projectRoot) {
268
+ const rel = path.relative(projectRoot, syncDir).split(path.sep).join("/");
269
+ // Local install → the binary is not on the root's PATH; point at it
270
+ // repo-relative (child_process resolves a command containing a separator
271
+ // against the spawn cwd = the agent's root), because an ABSOLUTE path in a
272
+ // committed root config is wrong on every other machine.
273
+ const hasLocalBin = existsSync(path.join(syncDir, "node_modules", ".bin", "n8n-decanter"));
274
+ const command = hasLocalBin ? `${rel}/node_modules/.bin/n8n-decanter` : "n8n-decanter";
275
+ const commandNote = hasLocalBin
276
+ ? ` The command is that path because decanter is installed inside the sync
277
+ dir, not globally. Keep it repo-relative — an absolute path in a committed
278
+ root config breaks for everyone else who clones.`
279
+ : ` The bare command name assumes a global install (npm i -g n8n-decanter).
280
+ If you instead run npm install inside the sync dir, nothing is on the
281
+ root's PATH — use ${rel}/node_modules/.bin/n8n-decanter (repo-relative;
282
+ an absolute path in a committed root config breaks for everyone else).`;
283
+ return `
284
+ This sync dir is nested inside ${projectRoot}. Agents load the wiring just
285
+ scaffolded here only when they are STARTED here: .mcp.json is merged from
286
+ every ancestor of the launch dir (so a nested one is invisible from above),
287
+ and .claude/settings.json is read from the launch dir alone.
288
+
289
+ ${style.bold("A. Recommended — start the agent in this dir:")}
290
+
291
+ cd "${syncDir}" && claude # or opencode, codex, …
292
+
293
+ Everything just scaffolded then applies unchanged, with no further config.
294
+ Its one cost: that project's own .claude/settings.json does not load then
295
+ (its .mcp.json still does, via the ancestor walk).
296
+
297
+ ${style.bold(`B. Or wire up ${projectRoot} by hand — both halves, or it still fails:`)}
298
+
299
+ ${projectRoot}/.mcp.json — merge into the servers already there (the
300
+ "n8n-docs" entry needs no dir, copy it over unchanged):
301
+
302
+ {
303
+ "mcpServers": {
304
+ "n8n-instance": {
305
+ "command": "${command}",
306
+ "args": ["mcp", "connect"],
307
+ "env": { "N8N_DECANTER_DIR": "${rel}" }
308
+ }
309
+ }
310
+ }
311
+
312
+ N8N_DECANTER_DIR is what makes decanter find this dir at all — its config
313
+ search only walks UP from where it was started. It resolves against the
314
+ agent's working dir, so keep it repo-relative.
315
+
316
+ ${commandNote}
317
+
318
+ ${projectRoot}/opencode.json — the same two halves:
319
+
320
+ {
321
+ "mcp": {
322
+ "n8n-instance": {
323
+ "type": "local",
324
+ "command": ["${command}", "mcp", "connect"],
325
+ "environment": { "N8N_DECANTER_DIR": "${rel}" }
326
+ }
327
+ }
328
+ }
329
+
330
+ ${projectRoot}/.claude/settings.json — hooks and permissions, every path
331
+ and every relative glob prefixed with ${rel}:
332
+
333
+ {
334
+ "hooks": {
335
+ "PostToolUse": [
336
+ { "matcher": "Edit|Write|MultiEdit",
337
+ "hooks": [{ "type": "command", "command": "node ${rel}/.claude/hooks/verify.mjs" }] },
338
+ { "matcher": "mcp__n8n-instance__update_workflow",
339
+ "hooks": [{ "type": "command", "command": "node ${rel}/.claude/hooks/rename-refs.mjs" }] }
340
+ ],
341
+ "SessionStart": [
342
+ { "hooks": [{ "type": "command", "command": "node ${rel}/.claude/hooks/mcp-route-check.mjs" }] }
343
+ ]
344
+ },
345
+ "permissions": {
346
+ "deny": ["Read(${rel}/.env)", "Edit(${rel}/.env)", "Edit(**/.decanter.json)"]
347
+ }
348
+ }
349
+
350
+ Those two .env rules are the sharp end: a relative glob anchors at the
351
+ settings file that declares it, so copying Read(.env)/Edit(.env) up
352
+ verbatim would guard the ROOT's .env and silently stop protecting
353
+ ${rel}/.env — the credentials file. The allow list moves the same way:
354
+ Edit(workflows/**) becomes Edit(${rel}/workflows/**), Edit(shared/**)
355
+ becomes Edit(${rel}/shared/**). Everything else in
356
+ ${rel}/.claude/settings.json names no path and moves across verbatim —
357
+ copy it over too, the "push --force" Bash denies above all; the block
358
+ above is short because only the PATHS need rewriting, not because the
359
+ rest is optional.
360
+
361
+ Do not reach for \${CLAUDE_PROJECT_DIR}: it expands to the agent's project
362
+ root — the parent — so it reads as if it pointed here and never does.
363
+ `;
364
+ }
215
365
  /**
216
366
  * Modification-aware template refresh (dpkg conffile-style). First init copies
217
367
  * everything and records a baseline manifest. Re-init copies files new to the
@@ -321,16 +471,36 @@ async function refreshTemplate(srcDir, destDir, { force, protect, version, extra
321
471
  log.info(`copied template -> ${destDir}`);
322
472
  else
323
473
  log.info(`template up to date (${uptodate} unchanged${added.length ? `, ${added.length} added` : ""})`);
324
- // Permission rules do not hot-load, and `init` is normally run from INSIDE the
325
- // session they are meant to constrain — so the deny rules it just wrote (no
326
- // edits to `.decanter.json`, no reads of `.env`, no `push --force`) are inert
327
- // until that session restarts. The docs said "restart" only for the skills
328
- // plugin, so the rules that actually gate the agent went unmentioned: the tool
329
- // knew something the user needed and never said it. Only when the file is new
330
- // — re-running init in a set-up dir must not nag.
331
- const settingsFile = path.join(".claude", "settings.json");
332
- if (added.includes(settingsFile)) {
333
- log.info(style.dim(" agent permission rules take effect on the NEXT session — restart your agent (or /reload) so they apply"));
474
+ // None of the agent wiring hot-loads, and `init` is normally run from INSIDE
475
+ // the session it is meant to configure: permission rules (no edits to
476
+ // `.decanter.json`, no reads of `.env`, no `push --force`), the guarded
477
+ // `n8n-instance` MCP server, and the session hooks are all read at agent
478
+ // startup, so everything just written is inert for it. The docs said
479
+ // "restart" only for the skills plugin, so the wiring that actually gates the
480
+ // agent went unmentioned: the tool knew something the user needed and never
481
+ // said it. Only when a file is new — re-running init in a set-up dir must not
482
+ // nag. What "make it live" MEANS then splits on the layout (see below).
483
+ const agentFiles = [path.join(".claude", "settings.json"), ".mcp.json", "opencode.json"];
484
+ if (agentFiles.some((f) => added.includes(f))) {
485
+ // Plan 81: those same three files are the ones a nested sync dir never gets
486
+ // to use — an agent started at the surrounding project root loads none of
487
+ // them. So the note rides the same gate — it answers "here is what it takes
488
+ // to make what I just wrote live" — and a standalone dir (the shape this all
489
+ // assumed until now) stays completely silent.
490
+ const projectRoot = projectRootAbove(destDir);
491
+ // Plan 83: which line is TRUE depends on that same detection, so branch on
492
+ // it rather than printing "restart" unconditionally and appending the
493
+ // nested note. In a nested dir a restart is not merely insufficient, it is
494
+ // a dead end — startup discovery walks UP from the launch dir, so a session
495
+ // started at the root re-misses this file every time. A blind agent read
496
+ // the old unconditional line, concluded "restart", and had no next idea.
497
+ if (projectRoot === null) {
498
+ log.info(style.dim(" restart your agent (or /reload) before working here — the MCP servers, permission rules and hooks just scaffolded load at agent STARTUP, so this session is still unconfigured"));
499
+ }
500
+ else {
501
+ log.info(style.dim(" the MCP servers, permission rules and hooks just scaffolded load at agent STARTUP, from the dir the agent was STARTED in — so a restart makes them live only for an agent started in this dir. Started higher up, no restart ever loads them; use A or B below."));
502
+ log.info(nestedWiringNote(destDir, projectRoot));
503
+ }
334
504
  }
335
505
  }
336
506
  /**
@@ -758,8 +758,13 @@ export async function writeScenario(dir, opts, log) {
758
758
  else {
759
759
  log.ok(`scenario "${name}" written from ${seededFrom} -> ${rel} (${size}) — no gaps; replay with \`preflight --simulate --scenario ${name}\``);
760
760
  }
761
+ // Both remedies are named because "trim it" alone left the reader hunting for
762
+ // a flag that does not exist (the `--trim` idea was retired — the file is
763
+ // hand-editable by design, and `--scaffold` is the no-capture path). The
764
+ // window is real and worth stating: `scenario create` does NOT commit, so
765
+ // nothing is in history until the next pull/push sweeps the folder.
761
766
  if (bytes > 1024 * 1024) {
762
- log.warn(`that scenario is ${size} and \`scenarios/\` is TRACKED — the next pull or push auto-commits the whole workflow folder, so it lands in git history permanently. Trim it before that, or keep the capture out of the scenario.`);
767
+ log.warn(`that scenario is ${size} and \`scenarios/\` is TRACKED — nothing is committed yet, but the next pull or push auto-commits the whole workflow folder and it lands in git history permanently. Before that: cut items out of "data.resultData.runData" by hand (the file is indented for exactly that), or re-create it with \`--scaffold\` instead of \`--execution\` — that carries no capture data at all, and you author the pins.`);
763
768
  }
764
769
  if (notExercised.length > 0) {
765
770
  log.info(`pinned ${notExercised.length} node${notExercised.length === 1 ? "" : "s"} to an EMPTY run — the capture never reached ${notExercised.length === 1 ? "it" : "them"}: ${notExercised.join(", ")}. That is what makes this scenario usable with \`test\` too; review the claim, and give one real output if a branch should have run.`);