archgen-mcp 0.0.6 → 0.0.7

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/README.md CHANGED
@@ -42,7 +42,7 @@ Requires **Node.js >= 20** (the official MCP SDK v2 floor).
42
42
  | `archgen_tasks` | next wave · set status · list · resume snapshot · **contract** (the assembled worker prompt) |
43
43
  | `archgen_docs` | navigate markdown artifacts: section index, one section, token map, reference integrity |
44
44
  | `archgen_registry` | refresh / read the AGENTS.md features registry |
45
- | `archgen_codegraph` | install + wire + index CodeGraph and its MCP |
45
+ | `archgen_codegraph` | install CodeGraph if it is missing · wire its MCP · index the project |
46
46
  | `archgen_setup` | doctor · bootstrap servers · install/remove the MCP · list harnesses · **harness_info** (a platform's capabilities) |
47
47
  | `archgen_ui` | drive the VS Code extension in real time |
48
48
  | `archgen_skills` | match / install / load external capability skills |
@@ -90,7 +90,7 @@ The skill declares the server version it needs:
90
90
  ```yaml
91
91
  metadata:
92
92
  requires:
93
- archgen_mcp: ">=0.0.6"
93
+ archgen_mcp: ">=x.y.z"
94
94
  ```
95
95
 
96
96
  `archgen_setup { action: 'doctor' }` reports a mismatch, `bootstrap` warns
@@ -112,14 +112,23 @@ implementation of any capability, only a second way to reach it.
112
112
 
113
113
  ## Safety
114
114
 
115
+ - The server owns a **boundary**: the directory it was started in. A tool's
116
+ `projectRoot` argument selects a directory *inside* that boundary — it
117
+ cannot redefine it, so a model cannot move the edge by naming a different
118
+ root.
115
119
  - Every path argument is resolved against the project root and refused if it
116
- escapes; symlinked targets are never written through.
120
+ escapes; symlinked targets are never written through, and a symlinked
121
+ *parent* is caught by comparing real paths.
122
+ - The artifact space is anchored to the project: a `.archgen` directory
123
+ elsewhere on the machine is not this project's artifacts.
117
124
  - MCP-config writes are approval-gated (`approve: true` / `--yes`); without
118
125
  approval the tool reports its **plan** and writes nothing.
119
126
  - A config file that cannot be parsed is reported, never rewritten.
120
127
  - Writes are atomic (temp file + rename): readers see old-or-new, never a
121
- partial file.
122
- - UI commands are allowlisted, workspace-scoped, expiring, and idempotent.
128
+ partial file. A read-modify-write — a status update, a config merge — runs
129
+ under an exclusive lock, so two workers cannot lose each other's change.
130
+ - UI commands are allowlisted, workspace-scoped, expiring, and idempotent;
131
+ the command queue is pruned, so it cannot grow without bound.
123
132
 
124
133
  ## License
125
134
 
@@ -15,7 +15,7 @@
15
15
  "mcp (opencode), context_servers (zed). Codex CLI uses TOML ([mcp_servers.<id>]).",
16
16
  "",
17
17
  "COMMAND SHAPE: the server entries below are the ones the official installers",
18
- "write — keep them byte-compatible, or the MCP server never connects.",
18
+ "write \u2014 keep them byte-compatible, or the MCP server never connects.",
19
19
  "The CodeGraph server also runs a FILE WATCHER, so the index stays live after",
20
20
  "the one-time init; nothing is re-indexed by hand.",
21
21
  "",
@@ -25,135 +25,365 @@
25
25
  ],
26
26
  "package": "@colbymchenry/codegraph",
27
27
  "entries": {
28
- "stdio": { "type": "stdio", "command": "codegraph", "args": ["serve", "--mcp"] },
29
- "stdioNoType": { "command": "codegraph", "args": ["serve", "--mcp"] },
30
- "opencodeLocal": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true }
28
+ "stdio": {
29
+ "type": "stdio",
30
+ "command": "codegraph",
31
+ "args": [
32
+ "serve",
33
+ "--mcp"
34
+ ]
35
+ },
36
+ "stdioNoType": {
37
+ "command": "codegraph",
38
+ "args": [
39
+ "serve",
40
+ "--mcp"
41
+ ]
42
+ },
43
+ "opencodeLocal": {
44
+ "type": "local",
45
+ "command": [
46
+ "codegraph",
47
+ "serve",
48
+ "--mcp"
49
+ ],
50
+ "enabled": true
51
+ }
31
52
  },
32
53
  "npxEntries": {
33
- "stdio": { "type": "stdio", "command": "npx", "args": ["-y", "@colbymchenry/codegraph", "serve", "--mcp"] },
34
- "stdioNoType": { "command": "npx", "args": ["-y", "@colbymchenry/codegraph", "serve", "--mcp"] },
35
- "opencodeLocal": { "type": "local", "command": ["npx", "-y", "@colbymchenry/codegraph", "serve", "--mcp"], "enabled": true }
54
+ "stdio": {
55
+ "type": "stdio",
56
+ "command": "npx",
57
+ "args": [
58
+ "-y",
59
+ "@colbymchenry/codegraph",
60
+ "serve",
61
+ "--mcp"
62
+ ]
63
+ },
64
+ "stdioNoType": {
65
+ "command": "npx",
66
+ "args": [
67
+ "-y",
68
+ "@colbymchenry/codegraph",
69
+ "serve",
70
+ "--mcp"
71
+ ]
72
+ },
73
+ "opencodeLocal": {
74
+ "type": "local",
75
+ "command": [
76
+ "npx",
77
+ "-y",
78
+ "@colbymchenry/codegraph",
79
+ "serve",
80
+ "--mcp"
81
+ ],
82
+ "enabled": true
83
+ }
36
84
  },
37
85
  "archgenPackage": "archgen-mcp",
38
86
  "archgenEntries": {
39
- "stdio": { "type": "stdio", "command": "archgen-mcp", "args": [] },
40
- "stdioNoType": { "command": "archgen-mcp", "args": [] },
41
- "opencodeLocal": { "type": "local", "command": ["archgen-mcp"], "enabled": true }
87
+ "stdio": {
88
+ "type": "stdio",
89
+ "command": "archgen-mcp",
90
+ "args": []
91
+ },
92
+ "stdioNoType": {
93
+ "command": "archgen-mcp",
94
+ "args": []
95
+ },
96
+ "opencodeLocal": {
97
+ "type": "local",
98
+ "command": [
99
+ "archgen-mcp"
100
+ ],
101
+ "enabled": true
102
+ }
42
103
  },
43
104
  "archgenNpxEntries": {
44
- "stdio": { "type": "stdio", "command": "npx", "args": ["-y", "archgen-mcp"] },
45
- "stdioNoType": { "command": "npx", "args": ["-y", "archgen-mcp"] },
46
- "opencodeLocal": { "type": "local", "command": ["npx", "-y", "archgen-mcp"], "enabled": true }
105
+ "stdio": {
106
+ "type": "stdio",
107
+ "command": "npx",
108
+ "args": [
109
+ "-y",
110
+ "archgen-mcp"
111
+ ]
112
+ },
113
+ "stdioNoType": {
114
+ "command": "npx",
115
+ "args": [
116
+ "-y",
117
+ "archgen-mcp"
118
+ ]
119
+ },
120
+ "opencodeLocal": {
121
+ "type": "local",
122
+ "command": [
123
+ "npx",
124
+ "-y",
125
+ "archgen-mcp"
126
+ ],
127
+ "enabled": true
128
+ }
47
129
  },
48
130
  "harnesses": {
49
131
  "claude": {
50
132
  "label": "Claude Code",
51
133
  "codegraphTarget": "claude",
52
- "detectHome": [".claude", ".claude.json"],
53
- "detectProject": [".claude", ".mcp.json"],
134
+ "detectHome": [
135
+ ".claude",
136
+ ".claude.json"
137
+ ],
138
+ "detectProject": [
139
+ ".claude",
140
+ ".mcp.json"
141
+ ],
54
142
  "configs": {
55
- "global": { "path": "~/.claude.json", "key": "mcpServers", "entry": "stdio" },
56
- "project": { "path": ".mcp.json", "key": "mcpServers", "entry": "stdio" }
143
+ "global": {
144
+ "path": "~/.claude.json",
145
+ "format": "json",
146
+ "key": "mcpServers",
147
+ "entry": "stdio"
148
+ },
149
+ "project": {
150
+ "path": ".mcp.json",
151
+ "format": "json",
152
+ "key": "mcpServers",
153
+ "entry": "stdio"
154
+ }
57
155
  }
58
156
  },
59
157
  "cursor": {
60
158
  "label": "Cursor",
61
159
  "codegraphTarget": "cursor",
62
- "detectHome": [".cursor"],
63
- "detectProject": [".cursor"],
160
+ "detectHome": [
161
+ ".cursor"
162
+ ],
163
+ "detectProject": [
164
+ ".cursor"
165
+ ],
64
166
  "configs": {
65
- "global": { "path": "~/.cursor/mcp.json", "key": "mcpServers", "entry": "stdio" },
66
- "project": { "path": ".cursor/mcp.json", "key": "mcpServers", "entry": "stdio" }
167
+ "global": {
168
+ "path": "~/.cursor/mcp.json",
169
+ "format": "json",
170
+ "key": "mcpServers",
171
+ "entry": "stdio"
172
+ },
173
+ "project": {
174
+ "path": ".cursor/mcp.json",
175
+ "format": "json",
176
+ "key": "mcpServers",
177
+ "entry": "stdio"
178
+ }
67
179
  }
68
180
  },
69
181
  "opencode": {
70
182
  "label": "OpenCode",
71
183
  "codegraphTarget": "opencode",
72
- "detectHome": [".config/opencode"],
73
- "detectProject": [".opencode", "opencode.json", "opencode.jsonc"],
74
- "_note": "OpenCode's global config is JSONC (opencode.jsonc) — codegraph's own installer writes it correctly; our writer refuses non-JSON files on purpose, so this entry is a fallback only.",
184
+ "detectHome": [
185
+ ".config/opencode"
186
+ ],
187
+ "detectProject": [
188
+ ".opencode",
189
+ "opencode.json",
190
+ "opencode.jsonc"
191
+ ],
192
+ "_note": "OpenCode's global config is JSONC (opencode.jsonc) \u2014 codegraph's own installer writes it correctly; our writer refuses non-JSON files on purpose, so this entry is a fallback only.",
75
193
  "configs": {
76
- "global": { "path": "~/.config/opencode/opencode.json", "key": "mcp", "entry": "opencodeLocal" },
77
- "project": { "path": "opencode.json", "key": "mcp", "entry": "opencodeLocal" }
194
+ "global": {
195
+ "path": "~/.config/opencode/opencode.json",
196
+ "format": "json",
197
+ "key": "mcp",
198
+ "entry": "opencodeLocal"
199
+ },
200
+ "project": {
201
+ "path": "opencode.json",
202
+ "format": "json",
203
+ "key": "mcp",
204
+ "entry": "opencodeLocal"
205
+ }
78
206
  }
79
207
  },
80
208
  "codex": {
81
209
  "label": "Codex CLI",
82
210
  "codegraphTarget": "codex",
83
- "detectHome": [".codex"],
84
- "detectProject": [".codex"],
211
+ "detectHome": [
212
+ ".codex"
213
+ ],
214
+ "detectProject": [
215
+ ".codex"
216
+ ],
85
217
  "configs": {
86
- "global": { "path": "~/.codex/config.toml", "format": "toml", "key": "mcp_servers.codegraph", "entry": "toml" },
87
- "project": { "path": ".codex/config.toml", "format": "toml", "key": "mcp_servers.codegraph", "entry": "toml" }
218
+ "global": {
219
+ "path": "~/.codex/config.toml",
220
+ "format": "toml",
221
+ "entry": "stdioNoType"
222
+ },
223
+ "project": {
224
+ "path": ".codex/config.toml",
225
+ "format": "toml",
226
+ "entry": "stdioNoType"
227
+ }
88
228
  }
89
229
  },
90
230
  "gemini": {
91
231
  "label": "Gemini CLI",
92
232
  "codegraphTarget": "gemini",
93
- "detectHome": [".gemini"],
94
- "detectProject": [".gemini"],
233
+ "detectHome": [
234
+ ".gemini"
235
+ ],
236
+ "detectProject": [
237
+ ".gemini"
238
+ ],
95
239
  "configs": {
96
- "global": { "path": "~/.gemini/settings.json", "key": "mcpServers", "entry": "stdio" },
97
- "project": { "path": ".gemini/settings.json", "key": "mcpServers", "entry": "stdio" }
240
+ "global": {
241
+ "path": "~/.gemini/settings.json",
242
+ "format": "json",
243
+ "key": "mcpServers",
244
+ "entry": "stdio"
245
+ },
246
+ "project": {
247
+ "path": ".gemini/settings.json",
248
+ "format": "json",
249
+ "key": "mcpServers",
250
+ "entry": "stdio"
251
+ }
98
252
  }
99
253
  },
100
254
  "antigravity": {
101
255
  "label": "Antigravity",
102
256
  "codegraphTarget": "antigravity",
103
- "detectHome": [".gemini/config"],
104
- "detectProject": [".agents/mcp_config.json"],
257
+ "detectHome": [
258
+ ".gemini/config"
259
+ ],
260
+ "detectProject": [
261
+ ".agents/mcp_config.json"
262
+ ],
105
263
  "configs": {
106
- "global": { "path": "~/.gemini/config/mcp_config.json", "key": "mcpServers", "entry": "stdio" },
107
- "project": { "path": ".agents/mcp_config.json", "key": "mcpServers", "entry": "stdio" }
264
+ "global": {
265
+ "path": "~/.gemini/config/mcp_config.json",
266
+ "format": "json",
267
+ "key": "mcpServers",
268
+ "entry": "stdio"
269
+ },
270
+ "project": {
271
+ "path": ".agents/mcp_config.json",
272
+ "format": "json",
273
+ "key": "mcpServers",
274
+ "entry": "stdio"
275
+ }
108
276
  }
109
277
  },
110
278
  "kiro": {
111
279
  "label": "Kiro",
112
280
  "codegraphTarget": "kiro",
113
- "detectHome": [".kiro"],
114
- "detectProject": [".kiro"],
281
+ "detectHome": [
282
+ ".kiro"
283
+ ],
284
+ "detectProject": [
285
+ ".kiro"
286
+ ],
115
287
  "configs": {
116
- "global": { "path": "~/.kiro/settings/mcp.json", "key": "mcpServers", "entry": "stdioNoType" },
117
- "project": { "path": ".kiro/settings/mcp.json", "key": "mcpServers", "entry": "stdioNoType" }
288
+ "global": {
289
+ "path": "~/.kiro/settings/mcp.json",
290
+ "format": "json",
291
+ "key": "mcpServers",
292
+ "entry": "stdioNoType"
293
+ },
294
+ "project": {
295
+ "path": ".kiro/settings/mcp.json",
296
+ "format": "json",
297
+ "key": "mcpServers",
298
+ "entry": "stdioNoType"
299
+ }
118
300
  }
119
301
  },
120
302
  "windsurf": {
121
303
  "label": "Windsurf",
122
304
  "codegraphTarget": null,
123
- "detectHome": [".codeium/windsurf"],
124
- "detectProject": [".windsurf"],
305
+ "detectHome": [
306
+ ".codeium/windsurf"
307
+ ],
308
+ "detectProject": [
309
+ ".windsurf"
310
+ ],
125
311
  "configs": {
126
- "global": { "path": "~/.codeium/windsurf/mcp_config.json", "key": "mcpServers", "entry": "stdioNoType" }
312
+ "global": {
313
+ "path": "~/.codeium/windsurf/mcp_config.json",
314
+ "format": "json",
315
+ "key": "mcpServers",
316
+ "entry": "stdioNoType"
317
+ }
127
318
  }
128
319
  },
129
320
  "vscode": {
130
321
  "label": "VS Code / Copilot",
131
322
  "codegraphTarget": null,
132
323
  "detectHome": [],
133
- "detectProject": [".vscode", ".github/copilot-instructions.md"],
324
+ "detectProject": [
325
+ ".vscode",
326
+ ".github/copilot-instructions.md"
327
+ ],
134
328
  "configs": {
135
- "project": { "path": ".vscode/mcp.json", "key": "servers", "entry": "stdio" }
329
+ "project": {
330
+ "path": ".vscode/mcp.json",
331
+ "format": "json",
332
+ "key": "servers",
333
+ "entry": "stdio"
334
+ }
136
335
  }
137
336
  },
138
337
  "zed": {
139
338
  "label": "Zed",
140
339
  "codegraphTarget": null,
141
- "detectHome": [".config/zed"],
142
- "detectProject": [".zed"],
340
+ "detectHome": [
341
+ ".config/zed"
342
+ ],
343
+ "detectProject": [
344
+ ".zed"
345
+ ],
143
346
  "configs": {
144
- "global": { "path": "~/.config/zed/settings.json", "key": "context_servers", "entry": "stdioNoType" },
145
- "project": { "path": ".zed/settings.json", "key": "context_servers", "entry": "stdioNoType" }
347
+ "global": {
348
+ "path": "~/.config/zed/settings.json",
349
+ "format": "json",
350
+ "key": "context_servers",
351
+ "entry": "stdioNoType"
352
+ },
353
+ "project": {
354
+ "path": ".zed/settings.json",
355
+ "format": "json",
356
+ "key": "context_servers",
357
+ "entry": "stdioNoType"
358
+ }
146
359
  }
147
360
  },
148
361
  "trae": {
149
362
  "label": "Trae",
150
363
  "codegraphTarget": null,
151
- "detectHome": [".trae"],
152
- "detectProject": [".trae"],
364
+ "detectHome": [
365
+ ".trae"
366
+ ],
367
+ "detectProject": [
368
+ ".trae"
369
+ ],
153
370
  "configs": {
154
- "project": { "path": ".trae/mcp.json", "key": "mcpServers", "entry": "stdioNoType" }
371
+ "project": {
372
+ "path": ".trae/mcp.json",
373
+ "format": "json",
374
+ "key": "mcpServers",
375
+ "entry": "stdioNoType"
376
+ }
155
377
  }
156
378
  }
157
379
  },
158
- "codegraphNativeTargets": ["claude", "cursor", "opencode", "codex", "gemini", "antigravity", "kiro"]
380
+ "codegraphNativeTargets": [
381
+ "claude",
382
+ "cursor",
383
+ "opencode",
384
+ "codex",
385
+ "gemini",
386
+ "antigravity",
387
+ "kiro"
388
+ ]
159
389
  }
@@ -6,9 +6,17 @@ Begin both files with three YAML comment lines. Comments are ignored by every
6
6
  consumer (the MCP server, the VS Code extension, any YAML toolchain), so this is
7
7
  purely additive metadata — it can never break a parser:
8
8
 
9
+ **NEVER type the version yourself.** The `scaffold` action writes the generator
10
+ line with the RUNNING server's version, and you paste its output verbatim. A
11
+ version you write by hand is either stale or made up — and the provenance
12
+ header exists precisely so it is never either.
13
+
14
+ The shape of what `scaffold` returns (the version below is a sample of the
15
+ running server's, not a value to copy):
16
+
9
17
  ```yaml
10
18
  # schema_version: 1
11
- # generator: archgen v0.0.4
19
+ # generator: archgen v1.2.3
12
20
  # generated_at: 2026-08-26T09:00:00.000Z
13
21
  ```
14
22
 
@@ -16,11 +24,17 @@ The scaffold action returns these three lines ready to paste.
16
24
 
17
25
  Version token rules — code-defensive, NEVER guess:
18
26
 
19
- 1. If a `.archgen-version` stamp sits next to `SKILL.md` (the installed skill
20
- root) and reads as exact `MAJOR.MINOR.PATCH`, write `generator: archgen vX.Y.Z`.
21
- 2. Else if an adjacent `archgen.config.json` carries a semver-shaped
22
- `"version"` field, use that value the same way.
23
- 3. Else omit the version token entirely: plain `# generator: archgen`.
27
+ 1. The `scaffold` action already resolved the version for you. Copy its
28
+ header as-is.
29
+ 2. If you must write the header yourself (backfilling an artifact the tool
30
+ did not create), resolve the version in this order and never invent one:
31
+ a. The `.archgen-version` stamp next to the installed `SKILL.md`, when it
32
+ reads as exact `MAJOR.MINOR.PATCH`.
33
+ b. Otherwise the `"version"` field of an adjacent `archgen.config.json`,
34
+ when it is semver-shaped.
35
+ c. Otherwise omit the version token entirely: plain `# generator: archgen`.
36
+ 3. `npx archgen-skill migrate --apply` applies rules 2(a)–(c) to existing
37
+ artifacts for you, and backs each file up before it touches it.
24
38
 
25
39
  `generated_at` is one ISO-8601 timestamp per generation run (all files written
26
40
  by the same run share it). Keep the header at the very top of the file; never
@@ -111,12 +111,14 @@ release and refreshes it in place. The MCP server exposes its own tool set to
111
111
  the agent, so the skill does not restate the CLI's command surface — read it
112
112
  from the installed CLI (`codegraph help`) or the MCP tools themselves.
113
113
 
114
- > **Entry point — the `archgen_codegraph` tool.** It installs/upgrades the CLI,
115
- > wires every DETECTED harness with the correct per-platform shape, and builds
116
- > the project index — idempotently, with an approval gate before any config
117
- > write. It drives the official CLI; it never reimplements it.
114
+ > **Entry point — the `archgen_codegraph` tool.** It installs the CLI **if it
115
+ > is missing** (an installed CodeGraph is the user's to manage and is never
116
+ > upgraded behind their back), wires every DETECTED harness with the correct
117
+ > per-platform shape, and builds the project index — idempotently, with an
118
+ > approval gate before any config write. It drives the official CLI; it never
119
+ > reimplements it.
118
120
  > ```
119
- > archgen_codegraph { action: 'ensure' } # install + wire + index
121
+ > archgen_codegraph { action: 'ensure' } # install if missing + wire + index
120
122
  > archgen_codegraph { action: 'status' } # what is installed/wired/indexed
121
123
  > ```
122
124
 
@@ -16,11 +16,13 @@ import { createServer } from '../src/server.mjs';
16
16
  import { runCli } from '../src/cli.mjs';
17
17
 
18
18
  const argv = process.argv.slice(2);
19
- const CLI_COMMANDS = new Set(['exec', 'bootstrap', 'doctor', 'uninstall-mcp', 'help']);
20
19
  const first = argv[0];
21
- // 'serve' is the default, but any explicit CLI command OR any flag means the
22
- // caller wants the command-line facade, not the JSON-RPC stream.
23
- const wantsCli = first === 'serve' ? false : CLI_COMMANDS.has(first) || (first !== undefined && first.startsWith('-'));
20
+ // The stdio server is the DEFAULT, and only for no argument at all or the
21
+ // explicit word 'serve'. Anything else is the command-line facade — a known
22
+ // command, a flag, or an unknown word, which must say "unknown command"
23
+ // rather than silently start a server and block on stdin (which is what it
24
+ // used to do, making the mistake look like a hang).
25
+ const wantsCli = !(first === undefined || first === 'serve');
24
26
 
25
27
  if (wantsCli) {
26
28
  const code = await runCli(argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgen-mcp",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "MCP server for archgen \u2014 the single runtime that owns architecture-plan validation, wave execution, artifact navigation, CodeGraph bootstrap, harness MCP wiring, and live UI control for coding agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/context.mjs CHANGED
@@ -11,7 +11,7 @@ import { homedir } from 'node:os';
11
11
  import { dirname, isAbsolute, join, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { invalidInput } from './core/errors.mjs';
14
- import { containPath } from './core/fs-safety.mjs';
14
+ import { assertWithinRoot, containPath } from './core/fs-safety.mjs';
15
15
 
16
16
  const HERE = dirname(fileURLToPath(import.meta.url));
17
17
 
@@ -148,19 +148,59 @@ export function loadAsset(name) {
148
148
 
149
149
  /**
150
150
  * The per-call context: project root + resolved skill + versions.
151
+ *
152
+ * THE ANCHOR. `anchorRoot` is the boundary the server works inside. It is
153
+ * set when the server starts — the directory the server was launched in, or
154
+ * the project the CLI named — and it does NOT change per call.
155
+ *
156
+ * `projectRoot` selects a directory INSIDE the anchor. It is a selector, not
157
+ * a boundary: naming a different root cannot move the edge of what the
158
+ * server may read or write. Before this, every containment check was
159
+ * relative to a root the MODEL chose, so the guard defended nothing — a
160
+ * model that named `/etc` simply moved the boundary to `/etc`.
151
161
  */
152
162
  export class Context {
153
163
  /**
154
- * @param {{projectRoot?: string, home?: string, env?: Record<string, string|undefined>}} [opts]
164
+ * @param {{projectRoot?: string, anchorRoot?: string, home?: string, env?: Record<string, string|undefined>}} [opts]
155
165
  */
156
166
  constructor(opts = {}) {
157
167
  this.env = opts.env ?? process.env;
158
168
  this.home = opts.home ?? homedir();
159
- this.projectRoot = resolve(opts.projectRoot ?? this.env.ARCHGEN_PROJECT_ROOT ?? process.cwd());
169
+ // The anchor is the server's DECLARED root: what the server was started
170
+ // with. The cwd is only the fallback for a server started without one.
171
+ this.anchorRoot = resolve(
172
+ opts.anchorRoot ?? opts.projectRoot ?? this.env.ARCHGEN_PROJECT_ROOT ?? process.cwd(),
173
+ );
174
+ this.projectRoot = resolve(opts.projectRoot ?? this.anchorRoot);
160
175
  this.skill = resolveSkill(this.projectRoot, this.home, this.env);
161
176
  this.serverVersion = serverVersion();
162
177
  }
163
178
 
179
+ /**
180
+ * A context for a directory inside the anchor.
181
+ *
182
+ * The candidate is resolved and proved to be inside the anchor; a path
183
+ * outside it is refused with PERMISSION_DENIED. The returned context
184
+ * keeps the SAME anchor, so containment checks made with it cannot be
185
+ * widened either.
186
+ *
187
+ * @param {string} candidate
188
+ * @returns {Context}
189
+ */
190
+ withProjectRoot(candidate) {
191
+ if (typeof candidate !== 'string' || candidate.trim() === '') {
192
+ throw invalidInput('projectRoot must be a non-empty string', { projectRoot: candidate });
193
+ }
194
+ const abs = resolve(this.anchorRoot, candidate);
195
+ assertWithinRoot(this.anchorRoot, abs);
196
+ return new Context({
197
+ projectRoot: abs,
198
+ anchorRoot: this.anchorRoot,
199
+ env: this.env,
200
+ home: this.home,
201
+ });
202
+ }
203
+
164
204
  /**
165
205
  * Resolve a caller-supplied path and prove it stays inside the project root.
166
206
  * Relative paths are resolved against the project root.
@@ -36,6 +36,19 @@ export function loadBrief(briefsDir, kind, vars = {}) {
36
36
  return { kind: key, brief: text };
37
37
  }
38
38
 
39
+ /**
40
+ * The placeholder names a brief still contains.
41
+ *
42
+ * A dispatched brief must not carry a placeholder: a worker told to read
43
+ * `<slug dir>` looks for a directory with that literal name.
44
+ *
45
+ * @param {string} text
46
+ * @returns {string[]}
47
+ */
48
+ export function remainingPlaceholders(text) {
49
+ return [...new Set([...String(text ?? '').matchAll(/<([a-z][a-z. ]*)>/g)].map((m) => m[1]))];
50
+ }
51
+
39
52
  /**
40
53
  * Every brief, for a caller that wants them all.
41
54
  * @param {string} briefsDir