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.
- package/assets/harness-mcp.json +10 -1
- package/assets/platform-capabilities.json +11 -0
- package/assets/references/platforms.md +9 -1
- package/package.json +1 -1
- package/src/cli.mjs +2 -2
- package/src/core/codegraph-manager.mjs +2 -2
- package/src/core/doc-navigator.mjs +5 -1
- package/src/core/docs.mjs +24 -2
- package/src/core/fs-safety.mjs +64 -45
- package/src/core/guidance.mjs +142 -0
- package/src/core/harness-wiring.mjs +93 -9
- package/src/core/mcp-server-manager.mjs +4 -2
- package/src/core/plan-grapher.mjs +2 -1
- package/src/core/refs.mjs +2 -5
- package/src/core/scaffold.mjs +6 -0
- package/src/core/worker-contract.mjs +3 -2
- package/src/tools/docs.mjs +10 -2
- package/src/tools/help.mjs +10 -3
- package/src/tools/setup.mjs +2 -2
- package/src/tools/skills.mjs +13 -3
package/assets/harness-mcp.json
CHANGED
|
@@ -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 /
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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? */
|
package/src/core/fs-safety.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
57
|
-
*
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
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
|
|
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,
|
package/src/core/scaffold.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/src/tools/docs.mjs
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
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;
|
package/src/tools/help.mjs
CHANGED
|
@@ -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
|
-
-
|
|
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
|
package/src/tools/setup.mjs
CHANGED
|
@@ -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
|
|
package/src/tools/skills.mjs
CHANGED
|
@@ -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(
|
|
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 —
|
|
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
|
|
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
|