archgen-mcp 0.1.0 → 0.1.1

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.
@@ -320,12 +320,21 @@
320
320
  "vscode": {
321
321
  "label": "VS Code / Copilot",
322
322
  "codegraphTarget": null,
323
- "detectHome": [],
323
+ "detectHome": [
324
+ ".copilot"
325
+ ],
324
326
  "detectProject": [
325
327
  ".vscode",
326
328
  ".github/copilot-instructions.md"
327
329
  ],
328
330
  "configs": {
331
+ "global": {
332
+ "path": "mcp-config.json",
333
+ "home": "copilot",
334
+ "format": "json",
335
+ "key": "mcpServers",
336
+ "entry": "stdio"
337
+ },
329
338
  "project": {
330
339
  "path": ".vscode/mcp.json",
331
340
  "format": "json",
@@ -414,6 +414,17 @@
414
414
  "env"
415
415
  ],
416
416
  "urlField": "url"
417
+ },
418
+ "global": {
419
+ "path": "~/.copilot/mcp-config.json",
420
+ "key": "mcpServers",
421
+ "note": "$COPILOT_HOME replaces this location, and VS Code checks only one of them",
422
+ "stdioFields": [
423
+ "command",
424
+ "args",
425
+ "env"
426
+ ],
427
+ "urlField": "url"
417
428
  }
418
429
  },
419
430
  "mcpCli": "copilot mcp add; palette \"MCP: Add Server\"",
@@ -34,10 +34,18 @@ Never hand-edit the `AGENTS.md` registry table (`| Feature | Status | Updated |`
34
34
  Query `archgen_setup{action:'harness_info'}` for exact per-platform MCP schemas:
35
35
  - **Top-level keys vary:**
36
36
  - `mcpServers`: Claude Code, Cursor, Windsurf, Gemini CLI, Antigravity, Kiro, Trae
37
- - `servers`: VS Code / GitHub Copilot
37
+ - `servers`: VS Code (the `.vscode/mcp.json` workspace format)
38
38
  - `mcp.servers.<id>`: OpenCode (nested under `mcp.servers`, never flat `mcp.<id>`)
39
39
  - `context_servers`: Zed
40
40
  - `[mcp_servers.<id>]`: Codex CLI (TOML)
41
+ - **VS Code reads TWO formats.** The VS Code format is `servers` in
42
+ `.vscode/mcp.json`; the PORTABLE format is `mcpServers` in the Copilot
43
+ user's `mcp-config.json` — `$COPILOT_HOME/mcp-config.json`, or
44
+ `~/.copilot/mcp-config.json`. Copilot harness sessions read the portable
45
+ format directly, and `COPILOT_HOME` REPLACES the default (VS Code checks
46
+ only one of the two). The global wiring therefore goes to the Copilot home
47
+ in the portable format, so VS Code stable, VS Code Insiders, the Copilot
48
+ CLI and every workspace see the same server.
41
49
  - **Remote URL keys vary:** `url` vs `serverUrl` vs `httpUrl` (check `harness_info`).
42
50
  - **Automated wiring:** Use `archgen_setup{action:'install_mcp', approve:true}` rather than hand-editing config files.
43
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgen-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MCP server for archgen \u2014 the single runtime that owns architecture-plan validation, wave execution, artifact navigation, CodeGraph bootstrap and harness MCP wiring for coding agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.mjs CHANGED
@@ -204,7 +204,7 @@ export async function runCli(argv, io = {}) {
204
204
  codegraph: codegraph ? { bin: codegraph.bin, version: codegraph.version } : null,
205
205
  mcpBinary: status.binary,
206
206
  mcpTargets: status.targets,
207
- harnesses: detectHarnesses(registry, ctx.projectRoot, ctx.home).map((d) => d.id),
207
+ harnesses: detectHarnesses(registry, ctx.projectRoot, ctx.home, ctx.env).map((d) => d.id),
208
208
  };
209
209
  if (opts.json) {
210
210
  out(JSON.stringify(report, null, 2));
@@ -225,7 +225,7 @@ export async function runCli(argv, io = {}) {
225
225
  err('uninstall-mcp needs --yes (nothing was removed)');
226
226
  return 2;
227
227
  }
228
- const res = uninstallArchgenMcp(ctx.projectRoot, ctx.home);
228
+ const res = uninstallArchgenMcp(ctx.projectRoot, ctx.home, 'both', ctx.env);
229
229
  if (opts.json) out(JSON.stringify(res, null, 2));
230
230
  else {
231
231
  out(`removed ${res.removed.length} config(s)`);
@@ -263,7 +263,7 @@ export async function manageCodegraph(opts) {
263
263
 
264
264
  /** Wire the MCP server into every detected harness (native + fallback). */
265
265
  const wireHarnesses = async (cli) => {
266
- const detected = detectHarnesses(registry, projectRoot, home);
266
+ const detected = detectHarnesses(registry, projectRoot, home, env);
267
267
  const wanted = targets ?? detected.map((d) => d.id);
268
268
  const selected = detected.filter((d) => wanted.includes(d.id));
269
269
 
@@ -428,7 +428,7 @@ export async function manageCodegraph(opts) {
428
428
  report.cli.latest = m ? m[1] : null;
429
429
  }
430
430
  report.telemetry.state = telemetryState(cli?.bin ?? null);
431
- const detected = detectHarnesses(registry, projectRoot, home);
431
+ const detected = detectHarnesses(registry, projectRoot, home, env);
432
432
  report.detected = detected.map((d) => d.id);
433
433
  for (const d of detected) {
434
434
  for (const cfg of Object.values(d.spec.configs ?? {})) {
@@ -32,6 +32,7 @@ import { buildGraph, GraphError } from './graph.mjs';
32
32
  import { resolveSlugInput, ScopeError } from './scope.mjs';
33
33
  import { parseMarkdown, buildSections, findSection, anchorExists } from './docs.mjs';
34
34
  import { cycle, invalidInput, notFound } from './errors.mjs';
35
+ import { artifactSpaceMissing } from './guidance.mjs';
35
36
 
36
37
  // Overview emits at most this many files (sorted); above it, truncated:true.
37
38
  export const OVERVIEW_MAX_FILES = 100;
@@ -71,7 +72,10 @@ export function resolveDocScope(input, projectRoot = null) {
71
72
  scoped = resolveSlugInput(input, projectRoot);
72
73
  } catch (e) {
73
74
  if (e instanceof ScopeError) throw invalidInput(`doc-index: ${e.message}`, { input });
74
- throw invalidInput(`cannot read ${input} (missing or not a directory)`, { input });
75
+ // The artifact space does not exist yet. Say WHY, and what to do: the
76
+ // scaffold tool returns CONTENT and writes nothing, so the caller has to
77
+ // write it before any artifact tool can read it.
78
+ throw artifactSpaceMissing(null, input);
75
79
  }
76
80
  return {
77
81
  slugDir: scoped.root,
package/src/core/docs.mjs CHANGED
@@ -129,7 +129,22 @@ export function buildSections(doc) {
129
129
  return sections;
130
130
  }
131
131
 
132
- /** Resolve a section by slug, title, or tolerant slug variant. */
132
+ /**
133
+ * Resolve a section by slug, title, tolerant slug variant, or PREFIX.
134
+ *
135
+ * The PREFIX form matters: the skill's `§` markers use SHORT names
136
+ * (`§artifact-templates/plan-depth`) while the headings produce longer
137
+ * slugs (`plan-depth-by-scope-class`). Every `§` shorthand in the skill is
138
+ * a prefix of a real slug, so accepting a prefix makes the shorthand the
139
+ * skill teaches actually RESOLVABLE — an agent can pass exactly what it was
140
+ * told without a discovery round-trip first.
141
+ *
142
+ * A prefix that matches MORE THAN ONE section is refused, never guessed:
143
+ * two sections sharing a prefix means the caller must use the full slug.
144
+ *
145
+ * @param {any} doc
146
+ * @param {string} ref
147
+ */
133
148
  export function findSection(doc, ref) {
134
149
  const sections = buildSections(doc);
135
150
  const wanted = String(ref).trim();
@@ -142,7 +157,14 @@ export function findSection(doc, ref) {
142
157
  // Case-insensitive + collapsed-hyphen comparison: agents type
143
158
  // "FR-Catalog-01" or "fr-catalog-01" for the same heading.
144
159
  const lower = wanted.toLowerCase();
145
- return sections.find((s) => slugsMatch(s.slug.toLowerCase(), lower)) ?? null;
160
+ const byLoose = sections.find((s) => slugsMatch(s.slug.toLowerCase(), lower));
161
+ if (byLoose) return byLoose;
162
+ // The `§` shorthand: a PREFIX of the slug. Only an UNAMBIGUOUS prefix
163
+ // resolves — a guess would send the agent to the wrong section.
164
+ const needle = slugifyHeading(wanted).toLowerCase();
165
+ if (!needle) return null;
166
+ const byPrefix = sections.filter((s) => s.slug.toLowerCase().startsWith(needle));
167
+ return byPrefix.length === 1 ? byPrefix[0] : null;
146
168
  }
147
169
 
148
170
  /** Does `anchor` exist in the target document? */
@@ -18,7 +18,7 @@
18
18
  // skill/scripts/set-status.mjs — the MCP server must not be weaker than the
19
19
  // scripts it replaces.
20
20
  import { lstatSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
21
- import { dirname, resolve, sep } from 'node:path';
21
+ import { basename, dirname, join, resolve, sep } from 'node:path';
22
22
  import { conflict, permissionDenied } from './errors.mjs';
23
23
 
24
24
  /** Canonical refusal message — identical wording to the CLI's. */
@@ -41,20 +41,58 @@ export function assertNotSymlink(absPath) {
41
41
  if (st.isSymbolicLink()) throw permissionDenied(refuseSymlinkMessage(absPath), { path: absPath });
42
42
  }
43
43
 
44
+ /**
45
+ * The REAL path of a path, climbing to the nearest existing ancestor when
46
+ * the leaf does not exist yet — so a symlinked PARENT is caught too.
47
+ *
48
+ * Returns null when the chain cannot be resolved at all (a broken symlink,
49
+ * or a component that is not a directory).
50
+ *
51
+ * @param {string} abs
52
+ * @returns {string|null}
53
+ */
54
+ function realPathWithTail(abs) {
55
+ const tail = [];
56
+ let probe = resolve(abs);
57
+ for (;;) {
58
+ try {
59
+ const real = realpathSync(probe);
60
+ return tail.length ? join(real, ...tail) : real;
61
+ } catch (e) {
62
+ if (!e || e.code !== 'ENOENT') return null;
63
+ const parent = dirname(probe);
64
+ if (parent === probe) return null;
65
+ tail.unshift(basename(probe));
66
+ probe = parent;
67
+ }
68
+ }
69
+ }
70
+
44
71
  /**
45
72
  * Realpath-escape guard: refuse anything that lands outside `rootAbs`.
46
73
  *
47
- * TWO CHECKS, IN ORDER.
74
+ * TWO COMPARISONS, AND THE PATH IS ALLOWED IF EITHER SAYS IT IS INSIDE.
75
+ *
76
+ * 1. LEXICAL — the caller's spelling against the root's spelling.
77
+ * 2. REAL — where the path actually LANDS against where the root actually
78
+ * IS. This is the one that catches a symlinked PARENT redirecting a
79
+ * write elsewhere, and it is the only one that can answer the question
80
+ * when the two paths are spelled differently.
48
81
  *
49
- * 1. LEXICAL. After normalization, the path must be inside the root. This
50
- * always applies — it is the primary guard, and it cannot be skipped
51
- * because a path does not exist yet.
52
- * 2. REAL. The nearest existing ancestor is resolved and must also be
53
- * inside the root's real path. This is what catches a symlinked
54
- * PARENT directory that would redirect the write elsewhere.
82
+ * They can disagree, and they DO whenever the root and the path are spelled
83
+ * differently — which is the NORMAL case on macOS (`/tmp`, `/var`) and for
84
+ * any symlinked project: `process.cwd()` is the REAL path while the caller
85
+ * passes the LOGICAL one.
55
86
  *
56
- * The guard does NOT fail open: if the root cannot be resolved, only the
57
- * lexical check stands, and the lexical check is always applied.
87
+ * The old guard compared the LEXICAL path against the REAL root and refused
88
+ * on that mismatch, so a perfectly valid path inside the project was
89
+ * reported as "outside the project" — and the two names the SAME place. The
90
+ * real comparison is the one that can actually answer the question, and it
91
+ * must be allowed to.
92
+ *
93
+ * The guard does NOT fail open: if the real comparison cannot be made, the
94
+ * lexical one stands on its own, and a path outside the root by every
95
+ * measure we could make is always refused.
58
96
  *
59
97
  * @param {string} rootAbs
60
98
  * @param {string} absPath
@@ -62,43 +100,24 @@ export function assertNotSymlink(absPath) {
62
100
  export function assertWithinRoot(rootAbs, absPath) {
63
101
  const root = resolve(rootAbs);
64
102
  const abs = resolve(absPath);
65
- if (abs !== root && !abs.startsWith(root + sep)) {
66
- throw permissionDenied(
67
- `refusing a path outside the project: ${absPath} is not inside ${rootAbs}`,
68
- { path: absPath, root: rootAbs },
69
- );
70
- }
71
103
 
72
- let rootReal;
73
- try {
74
- rootReal = realpathSync(root);
75
- } catch {
76
- return; // the root itself does not exist yet — the lexical check stands
77
- }
78
- let probe = abs;
79
- for (;;) {
80
- let exists = true;
81
- try {
82
- lstatSync(probe);
83
- } catch {
84
- exists = false;
85
- }
86
- if (exists) {
87
- let real;
88
- try {
89
- real = realpathSync(probe);
90
- } catch {
91
- return; // broken chain: nothing writable through it
92
- }
93
- if (real !== rootReal && !real.startsWith(rootReal + sep)) {
94
- throw permissionDenied(refuseSymlinkMessage(absPath), { path: absPath, root: rootAbs });
95
- }
96
- return;
97
- }
98
- const parent = dirname(probe);
99
- if (parent === probe) return;
100
- probe = parent;
104
+ const rootReal = realPathWithTail(root);
105
+ const pathReal = realPathWithTail(abs);
106
+
107
+ const lexicalInside = abs === root || abs.startsWith(root + sep);
108
+ const realInside =
109
+ rootReal && pathReal ? pathReal === rootReal || pathReal.startsWith(rootReal + sep) : null;
110
+
111
+ if (realInside === true || (realInside === null && lexicalInside)) return;
112
+
113
+ if (lexicalInside) {
114
+ // Lexically inside, but it lands somewhere else: a symlinked parent.
115
+ throw permissionDenied(refuseSymlinkMessage(absPath), { path: absPath, root: rootAbs });
101
116
  }
117
+ throw permissionDenied(
118
+ `refusing a path outside the project: ${absPath} is not inside ${rootAbs}`,
119
+ { path: absPath, root: rootAbs },
120
+ );
102
121
  }
103
122
 
104
123
  /**
@@ -0,0 +1,142 @@
1
+ // guidance.mjs — every failure teaches the agent what to do NEXT, in ONE line.
2
+ //
3
+ // WHY THIS FILE EXISTS.
4
+ //
5
+ // An error that only says WHAT FAILED leaves the agent to guess, and a guess
6
+ // that fails the same way is a loop. An agent that called
7
+ // `archgen_refs{action:'read', section:'plan-depth'}` and got
8
+ // "no section 'plan-depth'" had no way to know the slug it was told to use
9
+ // was a shorthand for a longer one — so it burned a round-trip rediscovering
10
+ // the vocabulary, and did it again for the next section.
11
+ //
12
+ // So every archgen failure is ONE line that carries the cause, the fix, and
13
+ // the exact next call:
14
+ //
15
+ // <cause> — <the fix in a clause>. Next: <the call, ready to paste>
16
+ //
17
+ // The vocabulary is SHARED, so every tool teaches the same way. And where a
18
+ // failure can be prevented rather than explained, it is: the section
19
+ // resolver accepts the shorthand the skill teaches, so the question never
20
+ // comes up.
21
+ //
22
+ // `hint()` adds machine-readable next-step fields to the error DETAILS, so a
23
+ // client can act on them without parsing prose.
24
+ import { invalidInput, notFound } from './errors.mjs';
25
+
26
+ /**
27
+ * Compose a ONE-LINE teaching message.
28
+ *
29
+ * @param {string} cause what went wrong, in a few words
30
+ * @param {string} fix the fix, in one clause
31
+ * @param {string} action the exact next call, ready to paste
32
+ * @returns {string}
33
+ */
34
+ export function teach(cause, fix, action) {
35
+ return `${cause} — ${fix}. Next: ${action}`;
36
+ }
37
+
38
+ /**
39
+ * Machine-readable next-step fields, attached to an error's details.
40
+ *
41
+ * @param {{action?: string, reason?: string, alternatives?: string[], available?: unknown[]}} spec
42
+ * @returns {Record<string, unknown>}
43
+ */
44
+ export function hint(spec) {
45
+ const out = {};
46
+ if (spec.action) out.nextAction = spec.action;
47
+ if (spec.reason) out.reason = spec.reason;
48
+ if (spec.alternatives?.length) out.alternatives = spec.alternatives;
49
+ if (spec.available?.length) out.available = spec.available;
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * "The artifact space does not exist yet."
55
+ *
56
+ * `archgen_plan{action:'scaffold'}` is a GENERATOR: it returns the CONTENT
57
+ * of the files to create, and writes nothing. An agent that assumes otherwise
58
+ * gets "missing or not a directory" from every artifact tool, and the
59
+ * natural reading — "the tool is broken" — sends it hunting for a flag that
60
+ * does not exist.
61
+ *
62
+ * @param {string} slug the slug, when known
63
+ * @param {string} slugDir the path that was looked for
64
+ */
65
+ export function artifactSpaceMissing(slug, slugDir) {
66
+ return invalidInput(
67
+ teach(
68
+ `no artifact space at ${slugDir}`,
69
+ 'scaffold returns file CONTENT and writes nothing, so write it first',
70
+ "archgen_plan{action:'scaffold',scope:'LARGE',slug:'<slug>'}",
71
+ ),
72
+ hint({ action: "archgen_plan{action:'scaffold',scope:'LARGE',slug:'<slug>'}", reason: 'scaffold returns content; you write it.' }),
73
+ );
74
+ }
75
+
76
+ /**
77
+ * "That section does not exist."
78
+ *
79
+ * @param {string} topic the reference topic
80
+ * @param {string} section the section that was asked for
81
+ * @param {string[]} available the valid slugs
82
+ */
83
+ export function noSuchSection(topic, section, available) {
84
+ return notFound(
85
+ teach(
86
+ `no section '${section}' in '${topic}'`,
87
+ 'a prefix of the slug works too, if unambiguous',
88
+ `archgen_refs{action:'index',topic:'${topic}'}`,
89
+ ),
90
+ hint({ action: `archgen_refs{action:'index',topic:'${topic}'}`, reason: 'the index lists every exact slug.', available }),
91
+ );
92
+ }
93
+
94
+ /**
95
+ * "That skill is not installed."
96
+ *
97
+ * @param {string} slug the skill slug
98
+ */
99
+ export function skillNotInstalled(slug) {
100
+ return invalidInput(
101
+ teach(
102
+ `the capability skill '${slug}' is not installed`,
103
+ 'install it before dispatching the worker',
104
+ `archgen_skills{action:'install',skillId:'${slug}',approve:true}`,
105
+ ),
106
+ hint({ action: `archgen_skills{action:'install',skillId:'${slug}',approve:true}`, reason: 'a required skill must be on disk before dispatch.' }),
107
+ );
108
+ }
109
+
110
+ /**
111
+ * "That task id is not in the plan."
112
+ *
113
+ * @param {string} taskId the id that was asked for
114
+ * @param {string[]} available the task ids that exist
115
+ */
116
+ export function noSuchTask(taskId, available) {
117
+ return notFound(
118
+ teach(
119
+ `no task '${taskId}' in the plan`,
120
+ 'task ids are case-sensitive',
121
+ "archgen_tasks{action:'list',tasksYaml:'<tasks.yaml>'}",
122
+ ),
123
+ hint({ action: "archgen_tasks{action:'list',tasksYaml:'<tasks.yaml>'}", reason: 'the list carries every task id.', available }),
124
+ );
125
+ }
126
+
127
+ /**
128
+ * "That path is outside the project."
129
+ *
130
+ * @param {string} path the refused path
131
+ * @param {string} root the anchor the server is bound to
132
+ */
133
+ export function outsideProject(path, root) {
134
+ return invalidInput(
135
+ teach(
136
+ `the path is outside the project: ${path}`,
137
+ 'the server is anchored to one root and cannot read or write outside it',
138
+ `use a path under ${root}`,
139
+ ),
140
+ hint({ action: `use a path under ${root}`, reason: 'the anchor is the boundary.' }),
141
+ );
142
+ }
@@ -24,23 +24,103 @@ import { assertNotSymlink, assertWithinRoot, withFileLock, writeFileAtomic } fro
24
24
  import { mergeEntry, parseJsonc, removeEntry } from './jsonc-config.mjs';
25
25
  import { invalidInput } from './errors.mjs';
26
26
 
27
+ /**
28
+ * The Copilot home: `$COPILOT_HOME`, or `~/.copilot`.
29
+ *
30
+ * VS Code reads the Copilot CLI's MCP configuration from here, and the
31
+ * documentation is explicit that `COPILOT_HOME` REPLACES the default:
32
+ * "VS Code does not check both locations."
33
+ *
34
+ * So a user who sets it has their configuration in ONE place and the other
35
+ * is ignored. Writing to the ignored one is exactly the "the agent cannot
36
+ * see the MCP" failure — which is why the config location and the detection
37
+ * must both follow this same home.
38
+ *
39
+ * @param {Record<string, string|undefined>} [env]
40
+ * @param {string} [home]
41
+ * @returns {string}
42
+ */
43
+ export function copilotHome(env = process.env, home = homedir()) {
44
+ const fromEnv = env.COPILOT_HOME;
45
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv);
46
+ return join(home, '.copilot');
47
+ }
48
+
27
49
  /**
28
50
  * Detect which harnesses are present in this environment.
29
51
  * @param {any} registry the harness-mcp.json content
30
52
  * @param {string} projectRoot
31
53
  * @param {string} [home]
54
+ * @param {Record<string, string|undefined>} [env]
32
55
  * @returns {Array<{id: string, label: string, atHome: boolean, atProject: boolean, spec: any}>}
33
56
  */
34
- export function detectHarnesses(registry, projectRoot, home = homedir()) {
57
+ export function detectHarnesses(registry, projectRoot, home = homedir(), env = process.env) {
35
58
  const found = [];
36
59
  for (const [id, h] of Object.entries(registry.harnesses ?? {})) {
37
- const atHome = (h.detectHome ?? []).some((m) => existsSync(join(home, m)));
60
+ const atHome =
61
+ (h.detectHome ?? []).some((m) => existsSync(join(home, m))) ||
62
+ // A harness whose configuration lives in a NAMED home (the Copilot
63
+ // home) is present when that home exists. `COPILOT_HOME` replaces the
64
+ // default, and the client checks only one of them — so detecting the
65
+ // default alone would miss a user who set the variable, and wiring the
66
+ // default alone would write a file the client never reads.
67
+ namedHomeExists(h, env, home);
38
68
  const atProject = (h.detectProject ?? []).some((m) => existsSync(join(projectRoot, m)));
39
69
  if (atHome || atProject) found.push({ id, label: h.label, atHome, atProject, spec: h });
40
70
  }
41
71
  return found;
42
72
  }
43
73
 
74
+ /** True when the home a harness's configuration lives in exists. */
75
+ function namedHomeExists(harness, env, home) {
76
+ for (const cfg of Object.values(harness.configs ?? {})) {
77
+ if (cfg.home === 'copilot' && existsSync(copilotHome(env, home))) return true;
78
+ }
79
+ return false;
80
+ }
81
+
82
+ /**
83
+ * Where a harness's configuration file lives — and the root it must stay
84
+ * inside.
85
+ *
86
+ * THREE BASES:
87
+ * - `home: "copilot"` — the Copilot home (`$COPILOT_HOME`, or
88
+ * `~/.copilot`), with the path relative to it.
89
+ * - a path starting with `~/` — relative to the home directory.
90
+ * - anything else — relative to the project root.
91
+ *
92
+ * The path and its containment root come from ONE function on purpose: when
93
+ * they were computed separately, the root said "the home directory" while
94
+ * the path said "$COPILOT_HOME" — and the write was then refused by the very
95
+ * guard meant to protect it.
96
+ *
97
+ * @param {any} cfg the registry's config entry
98
+ * @param {{projectRoot: string, home: string, env?: Record<string, string|undefined>}} ctx
99
+ * @returns {{path: string, root: string}} the absolute path and its root
100
+ */
101
+ export function configTargetFor(cfg, ctx) {
102
+ const env = ctx.env ?? process.env;
103
+ if (cfg.home === 'copilot') {
104
+ const base = copilotHome(env, ctx.home);
105
+ return { path: join(base, cfg.path), root: base };
106
+ }
107
+ if (cfg.path.startsWith('~/')) {
108
+ return { path: expandHome(cfg.path, ctx.home), root: ctx.home };
109
+ }
110
+ return { path: join(ctx.projectRoot, cfg.path), root: ctx.projectRoot };
111
+ }
112
+
113
+ /**
114
+ * Where a harness's configuration file lives.
115
+ *
116
+ * @param {any} cfg the registry's config entry
117
+ * @param {{projectRoot: string, home: string, env?: Record<string, string|undefined>}} ctx
118
+ * @returns {string} the absolute path
119
+ */
120
+ export function configPathFor(cfg, ctx) {
121
+ return configTargetFor(cfg, ctx).path;
122
+ }
123
+
44
124
  /** Expand a leading `~/` to the home directory. */
45
125
  export function expandHome(p, home = homedir()) {
46
126
  return p.startsWith('~/') ? join(home, p.slice(2)) : p;
@@ -361,9 +441,10 @@ export function wireServer(opts) {
361
441
  fallbackCommand = serverId,
362
442
  skipHarnesses = [],
363
443
  targets = null,
444
+ env = process.env,
364
445
  } = opts;
365
446
 
366
- const detected = detectHarnesses(registry, projectRoot, home);
447
+ const detected = detectHarnesses(registry, projectRoot, home, env);
367
448
  // A caller that narrowed the set of harnesses must get exactly that set.
368
449
  // The native path honoured the filter and this one did not, so a request
369
450
  // for two harnesses wired ten.
@@ -383,9 +464,10 @@ export function wireServer(opts) {
383
464
  if (!cfg) continue;
384
465
  if (loc === 'global' && !d.atHome) continue;
385
466
  if (loc === 'project' && !d.atProject) continue;
386
- const absPath = cfg.path.startsWith('~') ? expandHome(cfg.path, home) : join(projectRoot, cfg.path);
467
+ const target = configTargetFor(cfg, { projectRoot, home, env });
468
+ const absPath = target.path;
387
469
  const entry = buildEntry(entries, npxEntries, cfg.entry, binPath, fallbackCommand);
388
- const root = loc === 'global' ? home : projectRoot;
470
+ const root = target.root;
389
471
  const res =
390
472
  cfg.format === 'toml'
391
473
  ? writeTomlConfig(absPath, serverId, entry, { approved, dryRun, root })
@@ -438,7 +520,7 @@ export function recordWiring(projectRoot, serverId, entries) {
438
520
  * @param {'project'|'global'|'both'} [location] which scope to remove
439
521
  * @returns {{removed: string[], skipped: string[]}}
440
522
  */
441
- export function removeWiring(projectRoot, serverId, home = homedir(), location = 'both') {
523
+ export function removeWiring(projectRoot, serverId, home = homedir(), location = 'both', env = process.env) {
442
524
  const p = join(projectRoot, '.archgen', '.mcp-wiring.json');
443
525
  let manifest = null;
444
526
  try {
@@ -463,9 +545,11 @@ export function removeWiring(projectRoot, serverId, home = homedir(), location =
463
545
  // The manifest is a file ON DISK in the project — repository content.
464
546
  // Its paths are therefore untrusted: a clone could ship a manifest
465
547
  // naming any file on the machine. A recorded path must resolve inside
466
- // the project root or the user's home, or it is refused.
467
- if (!pathIsWithin(abs, projectRoot) && !pathIsWithin(abs, home)) {
468
- skipped.push(`${abs} (outside the project and home directories)`);
548
+ // the project root, the user's home, or the Copilot home — those are
549
+ // the three places a harness's own configuration may live.
550
+ const allowedRoots = [projectRoot, home, copilotHome(env, home)];
551
+ if (!allowedRoots.some((r) => pathIsWithin(abs, r))) {
552
+ skipped.push(`${abs} (outside the project, home and Copilot directories)`);
469
553
  continue;
470
554
  }
471
555
  if (!existsSync(abs)) {
@@ -112,9 +112,11 @@ export function wireArchgenMcp(opts) {
112
112
  * Remove the archgen MCP entries this server wrote.
113
113
  * @param {string} projectRoot
114
114
  * @param {string} [home]
115
+ * @param {'project'|'global'|'both'} [location]
116
+ * @param {Record<string, string|undefined>} [env]
115
117
  */
116
- export function uninstallArchgenMcp(projectRoot, home = homedir(), location = 'both') {
117
- return removeWiring(projectRoot, 'archgen', home, location);
118
+ export function uninstallArchgenMcp(projectRoot, home = homedir(), location = 'both', env = process.env) {
119
+ return removeWiring(projectRoot, 'archgen', home, location, env);
118
120
  }
119
121
 
120
122
  /**
@@ -19,6 +19,7 @@ import { parseYaml } from './yaml.mjs';
19
19
  import { buildGraph, findCycle, computeWaves, dedupeDependencies, computeQualityStats, GraphError } from './graph.mjs';
20
20
  import { resolveSlugInput, ScopeError } from './scope.mjs';
21
21
  import { cycle, invalidInput, notFound } from './errors.mjs';
22
+ import { noSuchTask } from './guidance.mjs';
22
23
 
23
24
  // Whole-graph Mermaid above this node count is refused.
24
25
  export const MERMAID_WHOLE_MAX_NODES = 250;
@@ -238,7 +239,7 @@ export function planGraph(input, opts = {}) {
238
239
 
239
240
  if (node !== null) {
240
241
  const t = byId.get(node);
241
- if (!t) throw notFound(`no task '${node}' in ${tasksFile}`, { node, file: tasksFile });
242
+ if (!t) throw noSuchTask(node, uniqueTasks.map((x) => x.id));
242
243
  if (mermaid) {
243
244
  const up = [...bfsDistances(t.id, prerequisites)].filter(([id]) => id !== t.id).map(([id]) => id).sort();
244
245
  const down = [...bfsDistances(t.id, dependents)].filter(([id]) => id !== t.id).map(([id]) => id).sort();
package/src/core/refs.mjs CHANGED
@@ -23,6 +23,7 @@ import { existsSync, readFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { parseMarkdown, buildSections, findSection } from './docs.mjs';
25
25
  import { notFound } from './errors.mjs';
26
+ import { noSuchSection } from './guidance.mjs';
26
27
 
27
28
  /** The reference documents this server serves. */
28
29
  export const REF_TOPICS = [
@@ -104,11 +105,7 @@ export function readTopic(topic, opts) {
104
105
  const section = findSection(doc, opts.section);
105
106
  if (!section) {
106
107
  const available = buildSections(doc).map((s) => s.slug);
107
- throw notFound(`no section '${opts.section}' in '${resolved.topic}'`, {
108
- topic: resolved.topic,
109
- section: opts.section,
110
- available,
111
- });
108
+ throw noSuchSection(resolved.topic, opts.section, available);
112
109
  }
113
110
  return {
114
111
  topic: resolved.topic,
@@ -81,6 +81,12 @@ export function buildScaffold(ladder, scopeClass, opts = {}) {
81
81
  return {
82
82
  scopeClass: key,
83
83
  label: spec.label,
84
+ // THIS TOOL WRITES NOTHING. It returns the CONTENT of every file the
85
+ // scope class needs, and the caller writes them. An agent that assumes
86
+ // otherwise gets "missing or not a directory" from every artifact tool
87
+ // and reads it as a broken tool rather than an unwritten plan.
88
+ writes: false,
89
+ writeThese: artifacts.map((a) => a.path),
84
90
  intentDefaults: spec.intentDefaults ?? [],
85
91
  roundCap: spec.roundCap ?? null,
86
92
  artifacts,
@@ -30,7 +30,8 @@ import { dirname, join } from 'node:path';
30
30
  import { parseYaml } from './yaml.mjs';
31
31
  import { buildGraph, GraphError } from './graph.mjs';
32
32
  import { skillDirective } from './skill-manager.mjs';
33
- import { invalidInput, notFound } from './errors.mjs';
33
+ import { invalidInput } from './errors.mjs';
34
+ import { noSuchTask } from './guidance.mjs';
34
35
 
35
36
  /**
36
37
  * Assemble the worker prompt for one task.
@@ -64,7 +65,7 @@ export function buildWorkerContract(opts) {
64
65
  }
65
66
  const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
66
67
  const task = tasks.find((t) => t?.id === taskId);
67
- if (!task) throw notFound(`task '${taskId}' not found in ${tasksYaml}`, { taskId, file: tasksYaml });
68
+ if (!task) throw noSuchTask(taskId, tasks.map((t) => t?.id).filter(Boolean));
68
69
 
69
70
  // The dependency neighbourhood — what must exist BEFORE this task, and what
70
71
  // waits on it. Derived from depends_on, never hand-labelled.
@@ -6,6 +6,7 @@ import { join } from 'node:path';
6
6
  import { registerTool, projectRootArg } from './_register.mjs';
7
7
  import { docIndex } from '../core/doc-navigator.mjs';
8
8
  import { invalidInput } from '../core/errors.mjs';
9
+ import { hint, teach } from '../core/guidance.mjs';
9
10
 
10
11
  const ACTION_DESC = 'index | digest | section | refs | map | validate — see archgen_help.';
11
12
 
@@ -69,8 +70,15 @@ export function resolveSlugDir(ctx, explicit) {
69
70
  const active = activeSlugDir(ctx);
70
71
  if (!active) {
71
72
  throw invalidInput(
72
- `no .archgen/<slug>/ directory found under ${ctx.projectRoot} — generate a plan first, or pass slugDir`,
73
- { projectRoot: ctx.projectRoot },
73
+ teach(
74
+ `no .archgen/<slug>/ directory under ${ctx.projectRoot}`,
75
+ 'scaffold returns file CONTENT and writes nothing, so write it first',
76
+ "archgen_plan{action:'scaffold',scope:'LARGE',slug:'<slug>'}",
77
+ ),
78
+ hint({
79
+ action: "archgen_plan{action:'scaffold',scope:'LARGE',slug:'<slug>'}",
80
+ reason: 'scaffold returns content; you write it.',
81
+ }),
74
82
  );
75
83
  }
76
84
  return active;
@@ -43,9 +43,11 @@ Validates and analyzes a plan (tasks.yaml + plans/).
43
43
  - \`status\` only applies to mermaid output.
44
44
  - \`module\` cannot be combined with \`node\` or mermaid.
45
45
  - Whole-graph mermaid is refused above 250 nodes — scope it with \`node\` or \`module\`.
46
- - \`scaffold\` returns the artifact set a scope class MUST have — adding or dropping files is a right-sizing defect.
46
+ - \`scaffold\` returns the artifact set a scope class MUST have — adding or dropping files is a right-sizing defect. It returns each file's CONTENT and **writes nothing**: you write them under \`.archgen/<slug>/\`, then the artifact tools can read them.
47
47
  - \`brief\` returns a ready-to-send sub-agent prompt (verifier | review | survey | investigate). It derives the slug from \`tasksYaml\` when you do not pass one, and every path in it is ABSOLUTE — so it works from any working directory. The verifier and review briefs carry the graph, the reference-integrity result and the evidence map rendered, so the sub-agent makes no tool call of its own.
48
- - The verifier and reviewer return \`VERDICT:\` plus a COVERAGE table (every dimension PASS / FAIL / N-A). A report with findings but no coverage table is INCOMPLETE.`,
48
+ - The verifier and reviewer return \`VERDICT:\` plus a COVERAGE table (every dimension PASS / FAIL / N-A). A report with findings but no coverage table is INCOMPLETE.
49
+ - **Every error carries the next call.** If one fails, read the \`Next:\` line in the message — it is the exact call to make, and the \`Why:\` clause says why. Do not retry the same call.
50
+ - **Batch calls with \`allSettled\`**, not \`all\`: one bad argument must not discard the results of its siblings.`,
49
51
 
50
52
  tasks: `# archgen_tasks
51
53
 
@@ -100,6 +102,11 @@ you need are usually a few hundred. \`digest\` gives you the section index, \`se
100
102
  | \`validate\` | reference-integrity gate | slugDir? |
101
103
 
102
104
  **Gotchas**
105
+ - A section may be named by its exact slug, by its title, or by any unambiguous PREFIX of the slug (\`plan-depth\` finds \`plan-depth-by-scope-class\`).
106
+ - A skill that is not installed reports the install call to make.
107
+ - **Batch calls with \`allSettled\`**, not \`all\`: one bad argument must not discard the results of its siblings.
108
+
109
+ ## Gotchas
103
110
  - \`map\` is section-level, \`refs\` is line-level. Use \`map\` to navigate, \`refs\` when you need exact lines.
104
111
  - \`section\` takes a BARE SECTION SLUG straight from \`digest\` — e.g. \`section:'non-functional-requirements'\`. It resolves the section across the documents. A slug that matches more than one document comes back as an \`ambiguous\` list, not a guess. \`path\` and \`path#anchor\` also work.
105
112
  - Anchors are validated against the target document's real heading slugs — a renamed section is a broken anchor, not a silent pass.
@@ -221,7 +228,7 @@ Loads archgen's OWN protocol documents — the judgment layer: how to interview,
221
228
 
222
229
  **Gotchas**
223
230
  - Load the SECTION, not the topic: a topic is ~11 KB, a section is ~1-2 KB.
224
- - Section slugs are GitHub-exact (lowercase, punctuation dropped, whitespace to hyphens). Call \`index\` for the exact slug rather than guessing it.
231
+ - A section may be named by its exact slug, by its title, or by any unambiguous PREFIX of the slug. The skill's own \`§\` names are prefixes — \`§artifact-templates/plan-depth\` resolves to \`plan-depth-by-scope-class\` — so pass the \`§\` name straight through. Call \`index\` only when a prefix is ambiguous.
225
232
  - \`code-standards.md\` is served too when the skill is installed — but WORKERS read it as a plain file, because they may have no MCP server at all.`,
226
233
 
227
234
  all: `# archgen — the complete tool surface
@@ -58,7 +58,7 @@ export function registerSetupTools(server) {
58
58
  }
59
59
 
60
60
  if (args.action === 'harnesses') {
61
- const detected = detectHarnesses(registry, ctx.projectRoot, ctx.home);
61
+ const detected = detectHarnesses(registry, ctx.projectRoot, ctx.home, ctx.env);
62
62
  return {
63
63
  location,
64
64
  detected: detected.map((d) => ({
@@ -130,7 +130,7 @@ export function registerSetupTools(server) {
130
130
  // project's wiring in place, and vice versa.
131
131
  return {
132
132
  written: true,
133
- ...uninstallArchgenMcp(ctx.projectRoot, ctx.home, location),
133
+ ...uninstallArchgenMcp(ctx.projectRoot, ctx.home, location, ctx.env),
134
134
  };
135
135
  }
136
136
 
@@ -10,6 +10,7 @@ import { readFileSync } from 'node:fs';
10
10
  import { registerTool, projectRootArg } from './_register.mjs';
11
11
  import { loadAsset } from '../context.mjs';
12
12
  import { invalidInput } from '../core/errors.mjs';
13
+ import { hint, teach } from '../core/guidance.mjs';
13
14
  import {
14
15
  matchSkills,
15
16
  installSkill,
@@ -108,7 +109,14 @@ export function registerSkillTools(server) {
108
109
  ? registry.routing
109
110
  : null);
110
111
  if (!entry) {
111
- throw invalidInput(`unknown skill '${wanted}' — call archgen_skills { action: 'list' }`, { skillId: wanted });
112
+ throw invalidInput(
113
+ teach(
114
+ `unknown skill '${wanted}'`,
115
+ 'the registry lists every id and slug',
116
+ "archgen_skills{action:'list'}",
117
+ ),
118
+ hint({ action: "archgen_skills{action:'list'}", reason: 'the registry is the vocabulary.' }),
119
+ );
112
120
  }
113
121
 
114
122
  if (args.action === 'digest') {
@@ -122,7 +130,7 @@ export function registerSkillTools(server) {
122
130
  installed: false,
123
131
  sections: [],
124
132
  searchedPaths: found.searchedPaths ?? [],
125
- hint: 'not installed — call archgen_skills { action: "install" } first',
133
+ hint: 'not installed — install it first: archgen_skills{action:\'install\',skillId:\'' + entry.slug + '\',approve:true}',
126
134
  };
127
135
  }
128
136
  const content = readFileSync(found.path, 'utf8');
@@ -157,7 +165,9 @@ export function registerSkillTools(server) {
157
165
  normative: s.normative,
158
166
  })),
159
167
  followInstruction: loaded.loaded ? followInstruction({ slug: entry.slug, path: loaded.path, domain: entry.domain }) : null,
160
- hint: loaded.loaded ? null : 'not installed — call archgen_skills { action: "install" } first',
168
+ hint: loaded.loaded
169
+ ? null
170
+ : 'not installed — install it first: archgen_skills{action:\'install\',skillId:\'' + entry.slug + '\',approve:true}',
161
171
  };
162
172
 
163
173
  // ONE section — the agent's lens, the same way archgen_docs serves