archgen-mcp 0.0.7 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -6
- package/assets/briefs/investigate.md +16 -17
- package/assets/briefs/review.md +41 -29
- package/assets/briefs/survey.md +17 -20
- package/assets/briefs/verifier.md +52 -40
- package/assets/harness-mcp.json +6 -6
- package/assets/platform-capabilities.json +387 -71
- package/assets/references/artifact-templates.md +67 -232
- package/assets/references/interview.md +83 -199
- package/assets/references/mcp-and-skills.md +35 -125
- package/assets/references/orchestration.md +96 -209
- package/assets/references/platforms.md +41 -110
- package/assets/skill-registry.json +10 -1
- package/assets/templates/adr.md +3 -3
- package/assets/templates/architecture.yaml +31 -28
- package/assets/templates/edge-matrix.md +8 -8
- package/assets/templates/mermaid.md +3 -10
- package/assets/templates/prd.md +15 -15
- package/assets/templates/tasks.yaml +25 -18
- package/package.json +2 -2
- package/src/cli.mjs +1 -2
- package/src/context.mjs +12 -42
- package/src/core/brief-context.mjs +106 -0
- package/src/core/brief-scope.mjs +218 -0
- package/src/core/briefs.mjs +109 -1
- package/src/core/codegraph-manager.mjs +12 -0
- package/src/core/doc-navigator.mjs +101 -17
- package/src/core/errors.mjs +1 -1
- package/src/core/harness-wiring.mjs +65 -22
- package/src/core/jsonc-config.mjs +706 -0
- package/src/core/plan-grapher.mjs +11 -3
- package/src/core/skill-manager.mjs +181 -13
- package/src/core/skill-roots.mjs +92 -0
- package/src/core/tokens.mjs +78 -0
- package/src/core/worker-contract.mjs +145 -18
- package/src/server.mjs +0 -3
- package/src/tools/help.mjs +17 -34
- package/src/tools/plan.mjs +36 -22
- package/src/tools/skills.mjs +67 -5
- package/src/tools/tasks.mjs +44 -18
- package/src/core/ui-channel.mjs +0 -222
- package/src/tools/ui.mjs +0 -67
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// brief-scope.mjs — derive the artifact space a dispatch brief is written
|
|
2
|
+
// against, and derive EVERY path the brief carries from ONE source.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS — two defects, one cause (paths were assembled by hand
|
|
5
|
+
// instead of resolved):
|
|
6
|
+
//
|
|
7
|
+
// 1. The brief action demanded `slug` even when the caller had already
|
|
8
|
+
// passed the absolute `tasks.yaml`. That path IS `.archgen/<slug>/
|
|
9
|
+
// tasks.yaml` — the slug is derivable — so the tool asked for the same
|
|
10
|
+
// fact twice in two different shapes, and a caller that omitted `slug`
|
|
11
|
+
// got "needs tasks.yaml, plans dir, slug dir — pass slug" for a brief
|
|
12
|
+
// it had already given everything for.
|
|
13
|
+
//
|
|
14
|
+
// 2. The emitted brief mixed path vocabularies: `tasks.yaml` was ABSOLUTE
|
|
15
|
+
// while `slug dir` and `plans dir` were PROJECT-RELATIVE. A brief is
|
|
16
|
+
// handed to a sub-agent whose working directory may be a worktree or
|
|
17
|
+
// another repo root; `ctx.contain()` resolves a relative path against
|
|
18
|
+
// the SERVER's project root, so the relative paths in the brief
|
|
19
|
+
// silently pointed at a different tree. A brief whose paths only work
|
|
20
|
+
// from one cwd is a broken brief.
|
|
21
|
+
//
|
|
22
|
+
// Every path in a brief is now absolute, and every one of them comes from the
|
|
23
|
+
// single scope resolved here.
|
|
24
|
+
import { realpathSync } from 'node:fs';
|
|
25
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
26
|
+
import { resolveSlugInput, ScopeError } from './scope.mjs';
|
|
27
|
+
import { invalidInput } from './errors.mjs';
|
|
28
|
+
|
|
29
|
+
// The slug is a directory name, never a path: a slug of "../../etc" must not
|
|
30
|
+
// be able to steer the brief's paths out of the artifact space.
|
|
31
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The REAL path of a path that may not exist yet.
|
|
35
|
+
*
|
|
36
|
+
* The scope gate proves paths by realpath, so the brief's paths are real
|
|
37
|
+
* paths. When the leaf does not exist yet — a brief written before the plan
|
|
38
|
+
* lands — the deepest EXISTING ancestor is realpath'd and the remaining
|
|
39
|
+
* segments are re-appended. The result is the path the file WILL have, not
|
|
40
|
+
* a different spelling of the same place.
|
|
41
|
+
*
|
|
42
|
+
* Without this, the same plan produced two different prefixes depending on
|
|
43
|
+
* whether tasks.yaml happened to exist: `/var/...` from the lexical
|
|
44
|
+
* fallback and `/private/var/...` from the realpath branch.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} abs
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
function realpathNearest(abs) {
|
|
50
|
+
const tail = [];
|
|
51
|
+
let cur = resolve(abs);
|
|
52
|
+
for (;;) {
|
|
53
|
+
try {
|
|
54
|
+
const real = realpathSync(cur);
|
|
55
|
+
return tail.length ? join(real, ...tail.slice().reverse()) : real;
|
|
56
|
+
} catch {
|
|
57
|
+
const parent = dirname(cur);
|
|
58
|
+
if (parent === cur) return resolve(abs);
|
|
59
|
+
tail.push(basename(cur));
|
|
60
|
+
cur = parent;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Derive the slug directory from a `tasks.yaml` path WITHOUT touching the
|
|
67
|
+
* filesystem — used when the file does not exist yet (a brief can be written
|
|
68
|
+
* before the plan lands) or when it cannot be realpath-resolved.
|
|
69
|
+
*
|
|
70
|
+
* The shape is EXACTLY the shape `resolveSlugInput` returns — `{root, slug}` —
|
|
71
|
+
* so the two branches cannot disagree about the field names. They did once,
|
|
72
|
+
* and the fallback silently produced `slugDir: undefined`.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} p
|
|
75
|
+
* @returns {{root: string, slug: string}|null}
|
|
76
|
+
*/
|
|
77
|
+
function deriveFromTasksPath(p) {
|
|
78
|
+
if (basename(p) !== 'tasks.yaml') return null;
|
|
79
|
+
const slugDir = dirname(p);
|
|
80
|
+
const slug = basename(slugDir);
|
|
81
|
+
if (!SLUG_RE.test(slug)) return null;
|
|
82
|
+
if (basename(dirname(slugDir)) !== '.archgen') return null;
|
|
83
|
+
return { root: realpathNearest(slugDir), slug };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Derive the slug directory from a `plans` directory path.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} p
|
|
90
|
+
* @returns {{root: string, slug: string}|null}
|
|
91
|
+
*/
|
|
92
|
+
function deriveFromPlansPath(p) {
|
|
93
|
+
if (basename(p) !== 'plans') return null;
|
|
94
|
+
const slugDir = dirname(p);
|
|
95
|
+
const slug = basename(slugDir);
|
|
96
|
+
if (!SLUG_RE.test(slug)) return null;
|
|
97
|
+
if (basename(dirname(slugDir)) !== '.archgen') return null;
|
|
98
|
+
return { root: realpathNearest(slugDir), slug };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the artifact space a brief is written against.
|
|
103
|
+
*
|
|
104
|
+
* The caller may name the space in any of three ways; they are equivalent and
|
|
105
|
+
* are reconciled here:
|
|
106
|
+
*
|
|
107
|
+
* - `tasksYaml` — the absolute path to `<slugDir>/tasks.yaml` (strongest:
|
|
108
|
+
* it names a real file, so the scope is proven, not assumed);
|
|
109
|
+
* - `planDir` — the absolute path to `<slugDir>/plans`;
|
|
110
|
+
* - `slug` — the slug name alone.
|
|
111
|
+
*
|
|
112
|
+
* @param {{
|
|
113
|
+
* tasksYaml?: string|null,
|
|
114
|
+
* planDir?: string|null,
|
|
115
|
+
* slug?: string|null,
|
|
116
|
+
* projectRoot: string,
|
|
117
|
+
* }} opts
|
|
118
|
+
* @returns {{slug: string, slugDir: string, plansDir: string, tasksYaml: string, source: 'tasksYaml'|'planDir'|'slug'}}
|
|
119
|
+
*/
|
|
120
|
+
export function resolveBriefScope(opts) {
|
|
121
|
+
const { tasksYaml = null, planDir = null, slug = null, projectRoot } = opts;
|
|
122
|
+
if (!projectRoot) throw invalidInput('brief scope needs a project root', {});
|
|
123
|
+
|
|
124
|
+
let slugDir = null;
|
|
125
|
+
let resolvedSlug = null;
|
|
126
|
+
let tasksFile = null;
|
|
127
|
+
let source = null;
|
|
128
|
+
|
|
129
|
+
if (tasksYaml) {
|
|
130
|
+
let scoped = null;
|
|
131
|
+
try {
|
|
132
|
+
scoped = resolveSlugInput(tasksYaml, projectRoot);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (e instanceof ScopeError) {
|
|
135
|
+
throw invalidInput(`tasks.yaml must live in .archgen/<slug>/ — ${tasksYaml} does not`, { file: tasksYaml });
|
|
136
|
+
}
|
|
137
|
+
// The file may not exist yet. The path SHAPE still proves the scope.
|
|
138
|
+
scoped = deriveFromTasksPath(tasksYaml);
|
|
139
|
+
if (!scoped) {
|
|
140
|
+
throw invalidInput(`cannot read ${tasksYaml} (missing or unreadable)`, { file: tasksYaml });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
slugDir = scoped.root;
|
|
144
|
+
resolvedSlug = scoped.slug;
|
|
145
|
+
// The tasks file is taken from the RESOLVED slug dir, not from the raw
|
|
146
|
+
// path the caller typed. `resolveSlugInput` realpath-resolves, so this is
|
|
147
|
+
// the same file — but it shares one prefix with the slug dir and the
|
|
148
|
+
// plans dir. A brief whose three paths carry two different prefixes
|
|
149
|
+
// (`/var/...` vs `/private/var/...` — the macOS symlink) reads as three
|
|
150
|
+
// different locations to anything comparing them.
|
|
151
|
+
tasksFile = join(slugDir, 'tasks.yaml');
|
|
152
|
+
source = 'tasksYaml';
|
|
153
|
+
} else if (planDir) {
|
|
154
|
+
const scoped = deriveFromPlansPath(planDir);
|
|
155
|
+
if (!scoped) {
|
|
156
|
+
throw invalidInput(`plans dir must live in .archgen/<slug>/plans — ${planDir} does not`, { file: planDir });
|
|
157
|
+
}
|
|
158
|
+
slugDir = scoped.root;
|
|
159
|
+
resolvedSlug = scoped.slug;
|
|
160
|
+
tasksFile = join(slugDir, 'tasks.yaml');
|
|
161
|
+
source = 'planDir';
|
|
162
|
+
} else if (slug) {
|
|
163
|
+
if (!SLUG_RE.test(slug)) {
|
|
164
|
+
throw invalidInput(`slug '${slug}' is not a valid slug (lowercase, dash-separated)`, { slug });
|
|
165
|
+
}
|
|
166
|
+
slugDir = realpathNearest(join(projectRoot, '.archgen', slug));
|
|
167
|
+
resolvedSlug = slug;
|
|
168
|
+
tasksFile = join(slugDir, 'tasks.yaml');
|
|
169
|
+
source = 'slug';
|
|
170
|
+
} else {
|
|
171
|
+
throw invalidInput('brief needs tasks.yaml, a plans dir, or a slug — pass one of them', {
|
|
172
|
+
action: 'brief',
|
|
173
|
+
missing: ['tasks.yaml', 'slug'],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The two spellings must agree. A caller that passes a tasks.yaml from one
|
|
178
|
+
// slug and the name of another has made a mistake the brief would
|
|
179
|
+
// otherwise paper over.
|
|
180
|
+
if (slug && resolvedSlug && slug !== resolvedSlug) {
|
|
181
|
+
throw invalidInput(`slug '${slug}' does not match the artifact space '${resolvedSlug}'`, {
|
|
182
|
+
slug,
|
|
183
|
+
actual: resolvedSlug,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
slug: resolvedSlug,
|
|
189
|
+
slugDir,
|
|
190
|
+
plansDir: join(slugDir, 'plans'),
|
|
191
|
+
tasksYaml: tasksFile,
|
|
192
|
+
source,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The template variables a brief is rendered with.
|
|
198
|
+
*
|
|
199
|
+
* Every path is ABSOLUTE. The relative forms are gone: they resolved against
|
|
200
|
+
* whatever cwd the reading agent happened to have.
|
|
201
|
+
*
|
|
202
|
+
* @param {{slug: string, slugDir: string, plansDir: string, tasksYaml: string}} scope
|
|
203
|
+
* @param {{featureRequest?: string|null, topic?: string|null, round?: number|string|null}} extra
|
|
204
|
+
* @returns {Record<string, string>}
|
|
205
|
+
*/
|
|
206
|
+
export function briefVars(scope, extra = {}) {
|
|
207
|
+
const vars = {
|
|
208
|
+
slug: scope.slug,
|
|
209
|
+
'slug dir': scope.slugDir,
|
|
210
|
+
'plans dir': scope.plansDir,
|
|
211
|
+
'tasks.yaml': scope.tasksYaml,
|
|
212
|
+
};
|
|
213
|
+
if (extra.featureRequest) vars['feature request'] = extra.featureRequest;
|
|
214
|
+
else vars['feature request'] = scope.slug;
|
|
215
|
+
if (extra.topic) vars.topic = extra.topic;
|
|
216
|
+
if (extra.round !== undefined && extra.round !== null && extra.round !== '') vars.round = String(extra.round);
|
|
217
|
+
return vars;
|
|
218
|
+
}
|
package/src/core/briefs.mjs
CHANGED
|
@@ -42,11 +42,15 @@ export function loadBrief(briefsDir, kind, vars = {}) {
|
|
|
42
42
|
* A dispatched brief must not carry a placeholder: a worker told to read
|
|
43
43
|
* `<slug dir>` looks for a directory with that literal name.
|
|
44
44
|
*
|
|
45
|
+
* The matcher is CASE-INSENSITIVE on the first letter. It used to require a
|
|
46
|
+
* lowercase first character, so `<Domain>` — the literal placeholder that
|
|
47
|
+
* shipped in every worker prompt — was invisible to it.
|
|
48
|
+
*
|
|
45
49
|
* @param {string} text
|
|
46
50
|
* @returns {string[]}
|
|
47
51
|
*/
|
|
48
52
|
export function remainingPlaceholders(text) {
|
|
49
|
-
return [...new Set([...String(text ?? '').matchAll(/<([
|
|
53
|
+
return [...new Set([...String(text ?? '').matchAll(/<([A-Za-z][A-Za-z. ]*)>/g)].map((m) => m[1]))];
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
/**
|
|
@@ -58,3 +62,107 @@ export function loadAllBriefs(briefsDir) {
|
|
|
58
62
|
for (const kind of BRIEF_KINDS) out[kind] = loadBrief(briefsDir, kind).brief;
|
|
59
63
|
return out;
|
|
60
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The context block every dispatch brief carries — the EVIDENCE map, the
|
|
68
|
+
* pre-rendered tool results, and the rules that keep the sub-agent from
|
|
69
|
+
* re-deriving what the brief already says.
|
|
70
|
+
*
|
|
71
|
+
* WHY THIS IS RENDERED BY THE TOOL AND NOT WRITTEN INTO EACH TEMPLATE:
|
|
72
|
+
*
|
|
73
|
+
* 1. EVIDENCE. The verifier used to be told to "read plans + tasks.yaml" —
|
|
74
|
+
* whole files, every round. The brief now carries the FILE LIST and the
|
|
75
|
+
* SECTION ANCHORS, so the agent reads the sections that matter instead
|
|
76
|
+
* of re-reading the plan. A map, not a copy: the agent decides what to
|
|
77
|
+
* open, and the map is what lets it decide.
|
|
78
|
+
*
|
|
79
|
+
* 2. PRE-RENDERED RESULTS. The review brief used to say "attach the
|
|
80
|
+
* mermaid graph and the docs-validate result" — which cost the
|
|
81
|
+
* orchestrator two extra tool calls per round, one of which (the graph)
|
|
82
|
+
* failed on every plan. The tool renders them once, into the brief.
|
|
83
|
+
*
|
|
84
|
+
* 3. THE FORBIDDEN LIST. Without it, a sub-agent reads the archgen skill
|
|
85
|
+
* and its references (the orchestrator's protocol, not its) and runs the
|
|
86
|
+
* archgen tool suite to re-derive what it was just handed. That is a
|
|
87
|
+
* second and third reading of the same facts — pure cost, and a chance
|
|
88
|
+
* to drift from the protocol the orchestrator is following.
|
|
89
|
+
*
|
|
90
|
+
* @param {{
|
|
91
|
+
* scope?: {slug?: string, slugDir?: string, plansDir?: string, tasksYaml?: string}|null,
|
|
92
|
+
* evidence?: {files: Array<{path: string, sections: Array<{slug: string, title: string}>}>, fileCount: number}|null,
|
|
93
|
+
* graph?: string|null,
|
|
94
|
+
* docsValidation?: {valid: boolean, broken: unknown[], warnings: unknown[]}|null,
|
|
95
|
+
* allowedCalls?: string[],
|
|
96
|
+
* }} ctx
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function renderBriefContext(ctx = {}) {
|
|
100
|
+
const out = [];
|
|
101
|
+
|
|
102
|
+
out.push('## CONTEXT — read this first');
|
|
103
|
+
out.push('');
|
|
104
|
+
out.push(
|
|
105
|
+
'Everything you need is in this brief. Do NOT read the archgen skill, its references, or any ' +
|
|
106
|
+
'protocol document — they are the ORCHESTRATOR\'s, and reading them re-derives context you were ' +
|
|
107
|
+
'already given.',
|
|
108
|
+
);
|
|
109
|
+
const allowed = ctx.allowedCalls ?? [];
|
|
110
|
+
out.push(
|
|
111
|
+
allowed.length
|
|
112
|
+
? `Do NOT run the archgen tool suite beyond these calls: ${allowed.join(', ')}.`
|
|
113
|
+
: 'Do NOT run the archgen tool suite at all.',
|
|
114
|
+
);
|
|
115
|
+
out.push(
|
|
116
|
+
'Read ONLY the files in EVIDENCE, and only the sections named. Do not read whole documents when a ' +
|
|
117
|
+
'section is named.',
|
|
118
|
+
);
|
|
119
|
+
out.push('');
|
|
120
|
+
|
|
121
|
+
if (ctx.scope) {
|
|
122
|
+
out.push('## EVIDENCE — the plan under review');
|
|
123
|
+
out.push('');
|
|
124
|
+
if (ctx.scope.tasksYaml) out.push(`- tasks: ${ctx.scope.tasksYaml}`);
|
|
125
|
+
if (ctx.scope.plansDir) out.push(`- plans: ${ctx.scope.plansDir}`);
|
|
126
|
+
if (ctx.scope.slugDir) out.push(`- artifacts: ${ctx.scope.slugDir}`);
|
|
127
|
+
out.push('');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (ctx.evidence?.files?.length) {
|
|
131
|
+
out.push('### Section index — pull the section, never the file');
|
|
132
|
+
out.push('');
|
|
133
|
+
for (const f of ctx.evidence.files) {
|
|
134
|
+
out.push(`- ${f.path}`);
|
|
135
|
+
for (const s of f.sections) out.push(` - § ${s.slug} — ${s.title}`);
|
|
136
|
+
}
|
|
137
|
+
out.push('');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (ctx.graph) {
|
|
141
|
+
out.push('### Dependency graph (rendered for you — do not re-fetch)');
|
|
142
|
+
out.push('');
|
|
143
|
+
out.push('```mermaid');
|
|
144
|
+
out.push(ctx.graph);
|
|
145
|
+
out.push('```');
|
|
146
|
+
out.push('');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (ctx.docsValidation) {
|
|
150
|
+
const broken = ctx.docsValidation.broken?.length ?? 0;
|
|
151
|
+
const warned = ctx.docsValidation.warnings?.length ?? 0;
|
|
152
|
+
out.push('### Reference-integrity result (rendered for you — do not re-fetch)');
|
|
153
|
+
out.push('');
|
|
154
|
+
out.push(`- valid: ${ctx.docsValidation.valid === true}`);
|
|
155
|
+
out.push(`- broken references: ${broken}`);
|
|
156
|
+
out.push(`- warnings: ${warned}`);
|
|
157
|
+
if (broken > 0) {
|
|
158
|
+
out.push('');
|
|
159
|
+
out.push('Broken references (each BLOCKS approval):');
|
|
160
|
+
for (const b of ctx.docsValidation.broken) {
|
|
161
|
+
out.push(`- ${b.file ?? ''}${b.line ? `:${b.line}` : ''} — ${b.kind ?? 'broken'}: ${b.ref ?? ''}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
out.push('');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return out.join('\n');
|
|
168
|
+
}
|
|
@@ -228,6 +228,18 @@ export async function manageCodegraph(opts) {
|
|
|
228
228
|
report.cli.action = 'would-install';
|
|
229
229
|
return { bin: null, version: null };
|
|
230
230
|
}
|
|
231
|
+
// NOT APPROVED — do not install.
|
|
232
|
+
//
|
|
233
|
+
// A global `npm install -g` is a write to the user's machine, exactly
|
|
234
|
+
// like a config write, and the tool documents the opposite of what the
|
|
235
|
+
// code used to do: "Without `approve: true` the tool prints its plan
|
|
236
|
+
// and writes nothing." The code installed a global program anyway.
|
|
237
|
+
// The gate now covers the install, so an unapproved call is a plan.
|
|
238
|
+
if (!approved) {
|
|
239
|
+
report.cli.action = 'would-install';
|
|
240
|
+
report.cli.detail = `not installed — pass approve:true to install ${CODEGRAPH_PACKAGE} globally`;
|
|
241
|
+
return { bin: null, version: null };
|
|
242
|
+
}
|
|
231
243
|
// Official install path; always the latest published release.
|
|
232
244
|
const inv = npmInvocation(['install', '-g', CODEGRAPH_PACKAGE], env);
|
|
233
245
|
const r = await runAsync(inv.command, inv.args, { timeout: 600000, shell: inv.shell, env });
|
|
@@ -445,44 +445,128 @@ export function docIndex(input, opts = {}) {
|
|
|
445
445
|
|
|
446
446
|
if (mode === 'section') {
|
|
447
447
|
if (!sectionRef) throw invalidInput('section mode requires a path', {});
|
|
448
|
+
|
|
448
449
|
const hash = sectionRef.indexOf('#');
|
|
449
450
|
const filePart = hash === -1 ? sectionRef : sectionRef.slice(0, hash);
|
|
450
451
|
const anchor = hash === -1 ? null : sectionRef.slice(hash + 1);
|
|
451
|
-
|
|
452
|
-
|
|
452
|
+
|
|
453
|
+
// The ref may name a FILE, a file#anchor, or a bare SECTION SLUG.
|
|
454
|
+
//
|
|
455
|
+
// The bare-slug form is not a convenience — it is REQUIRED for the
|
|
456
|
+
// digest -> section round trip to work. `digest` hands out
|
|
457
|
+
// `sections[].slug` (the GitHub-exact heading slug), so the obvious next
|
|
458
|
+
// call is `section: "<slug>"`. That call used to fail on EVERY plan,
|
|
459
|
+
// because the whole ref was treated as a FILE path and no file is named
|
|
460
|
+
// after a heading. The agent had to re-join `path` + '#' + `slug` by
|
|
461
|
+
// hand, and nothing — not the digest, not the schema, not archgen_help —
|
|
462
|
+
// said so.
|
|
463
|
+
const fileCandidates = [];
|
|
464
|
+
if (filePart) fileCandidates.push(join(repoRoot, filePart), join(slugDir, filePart));
|
|
465
|
+
if (fileScopeArg) fileCandidates.push(join(repoRoot, fileScopeArg), join(slugDir, fileScopeArg));
|
|
466
|
+
|
|
467
|
+
const hit = fileCandidates.find((p) => {
|
|
453
468
|
try {
|
|
454
469
|
return statSync(p).isFile();
|
|
455
470
|
} catch {
|
|
456
471
|
return false;
|
|
457
472
|
}
|
|
458
473
|
});
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
474
|
+
|
|
475
|
+
if (hit) {
|
|
476
|
+
let hitReal;
|
|
477
|
+
try {
|
|
478
|
+
hitReal = realpathSync(hit);
|
|
479
|
+
} catch {
|
|
480
|
+
hitReal = resolve(hit);
|
|
481
|
+
}
|
|
482
|
+
if (!hitReal.startsWith(slugDir + sep)) {
|
|
483
|
+
throw invalidInput(`section ${sectionRef} is outside ${input}`, { section: sectionRef });
|
|
484
|
+
}
|
|
485
|
+
const doc = parsedDocAt(hit);
|
|
486
|
+
if (!doc) throw notFound(`cannot read section ${sectionRef} (unreadable)`, { section: sectionRef });
|
|
487
|
+
if (anchor) {
|
|
488
|
+
const section = findSection(doc, anchor);
|
|
489
|
+
if (!section) {
|
|
490
|
+
throw notFound(`section ${sectionRef}: no section '${anchor}' in ${filePart}`, {
|
|
491
|
+
section: sectionRef,
|
|
492
|
+
file: relFromRoot(hit),
|
|
493
|
+
available: buildSections(doc).map((s) => s.slug),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
mode,
|
|
498
|
+
data: {
|
|
499
|
+
path: relFromRoot(hit),
|
|
500
|
+
anchor: section.slug,
|
|
501
|
+
line: section.line,
|
|
502
|
+
endLine: section.endLine,
|
|
503
|
+
text: section.body,
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
return { mode, data: { path: relFromRoot(hit), text: doc.lines.join('\n') } };
|
|
465
508
|
}
|
|
466
|
-
|
|
467
|
-
|
|
509
|
+
|
|
510
|
+
// No file matched: the ref is a SECTION SLUG (possibly with a '#anchor'
|
|
511
|
+
// whose file part was empty). Resolve it against the documents.
|
|
512
|
+
const wanted = anchor && !filePart ? anchor : filePart;
|
|
513
|
+
if (!wanted) {
|
|
514
|
+
throw notFound(`cannot read section ${sectionRef} (missing or not a file)`, { section: sectionRef });
|
|
468
515
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
516
|
+
|
|
517
|
+
const { docs } = collectDocs();
|
|
518
|
+
const hits = [];
|
|
519
|
+
for (const doc of docs) {
|
|
520
|
+
const section = findSection(doc, wanted);
|
|
521
|
+
if (section) hits.push({ doc, section });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (hits.length === 1) {
|
|
525
|
+
const { doc, section } = hits[0];
|
|
474
526
|
return {
|
|
475
527
|
mode,
|
|
476
528
|
data: {
|
|
477
|
-
path: relFromRoot(
|
|
529
|
+
path: relFromRoot(doc.abs),
|
|
478
530
|
anchor: section.slug,
|
|
531
|
+
title: section.title,
|
|
479
532
|
line: section.line,
|
|
480
533
|
endLine: section.endLine,
|
|
481
534
|
text: section.body,
|
|
482
535
|
},
|
|
483
536
|
};
|
|
484
537
|
}
|
|
485
|
-
|
|
538
|
+
|
|
539
|
+
if (hits.length > 1) {
|
|
540
|
+
// Ambiguous: report the candidates, never guess. A wrong guess costs
|
|
541
|
+
// the agent a whole extra read cycle.
|
|
542
|
+
return {
|
|
543
|
+
mode,
|
|
544
|
+
data: {
|
|
545
|
+
ambiguous: true,
|
|
546
|
+
ref: wanted,
|
|
547
|
+
matches: hits.map(({ doc, section }) => ({
|
|
548
|
+
path: relFromRoot(doc.abs),
|
|
549
|
+
section: section.slug,
|
|
550
|
+
title: section.title,
|
|
551
|
+
line: section.line,
|
|
552
|
+
endLine: section.endLine,
|
|
553
|
+
})),
|
|
554
|
+
hint: 'the section slug matches more than one document — fetch it as path#anchor, e.g. demo/docs/prd.md#install',
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Nothing matched: name the available vocabulary, so one failed call is
|
|
560
|
+
// enough to recover — the same courtesy archgen_refs already gives.
|
|
561
|
+
const available = [];
|
|
562
|
+
for (const doc of docs) {
|
|
563
|
+
for (const s of buildSections(doc)) available.push(`${relFromRoot(doc.abs)}#${s.slug}`);
|
|
564
|
+
}
|
|
565
|
+
throw notFound(`no section '${wanted}' under ${slug}`, {
|
|
566
|
+
section: sectionRef,
|
|
567
|
+
available: available.slice(0, 50),
|
|
568
|
+
availableCount: available.length,
|
|
569
|
+
});
|
|
486
570
|
}
|
|
487
571
|
|
|
488
572
|
if (mode === 'map') {
|
package/src/core/errors.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export const ownershipConflict = (message, details) => new ArchgenError('OWNERSH
|
|
|
46
46
|
export const conflict = (message, details) => new ArchgenError('CONFLICT', message, details);
|
|
47
47
|
/** A write was refused by a safety guard (symlink, outside-root, no approval). */
|
|
48
48
|
export const permissionDenied = (message, details) => new ArchgenError('PERMISSION_DENIED', message, details);
|
|
49
|
-
/** A required external actor is absent (
|
|
49
|
+
/** A required external actor is absent (a binary is missing, a service is down). */
|
|
50
50
|
export const unavailable = (message, details) => new ArchgenError('UNAVAILABLE', message, details);
|
|
51
51
|
/** An operation exceeded its deadline. */
|
|
52
52
|
export const timeout = (message, details) => new ArchgenError('TIMEOUT', message, details);
|
|
@@ -21,6 +21,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
|
21
21
|
import { homedir } from 'node:os';
|
|
22
22
|
import { join, resolve, sep } from 'node:path';
|
|
23
23
|
import { assertNotSymlink, assertWithinRoot, withFileLock, writeFileAtomic } from './fs-safety.mjs';
|
|
24
|
+
import { mergeEntry, parseJsonc, removeEntry } from './jsonc-config.mjs';
|
|
24
25
|
import { invalidInput } from './errors.mjs';
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -129,16 +130,40 @@ export function buildEntry(entries, npxEntries, entryKey, binPath, fallbackComma
|
|
|
129
130
|
return entry;
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Read + parse a JSON config; null when absent, {error} when unparseable.
|
|
135
|
+
*
|
|
136
|
+
* JSONC is accepted: opencode and Zed keep their settings as JSON-with-
|
|
137
|
+
* comments, and a config the writer cannot even read is a config it can
|
|
138
|
+
* never wire.
|
|
139
|
+
*/
|
|
133
140
|
export function readJsonConfig(abs) {
|
|
134
141
|
if (!existsSync(abs)) return { data: null, existed: false };
|
|
135
142
|
try {
|
|
136
|
-
|
|
143
|
+
const { data, error } = parseJsonc(readFileSync(abs, 'utf8'));
|
|
144
|
+
if (error) return { data: null, existed: true, error };
|
|
145
|
+
return { data, existed: true };
|
|
137
146
|
} catch (e) {
|
|
138
147
|
return { data: null, existed: true, error: e.message };
|
|
139
148
|
}
|
|
140
149
|
}
|
|
141
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The nested object for a new config file.
|
|
153
|
+
*
|
|
154
|
+
* The key may be a DOTTED PATH: opencode keeps its servers at
|
|
155
|
+
* `mcp.servers.<id>`, so a flat `{ "mcp.servers": … }` would put the entry
|
|
156
|
+
* where opencode never looks — the server would be installed and invisible.
|
|
157
|
+
*/
|
|
158
|
+
function nestedConfig(key, serverId, entry) {
|
|
159
|
+
const parts = key.split('.');
|
|
160
|
+
let value = { [serverId]: entry };
|
|
161
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
162
|
+
value = { [parts[i]]: value };
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
142
167
|
/**
|
|
143
168
|
* Merge a server entry into a JSON MCP config.
|
|
144
169
|
*
|
|
@@ -167,27 +192,36 @@ export function writeJsonConfig(abs, key, serverId, entry, opts) {
|
|
|
167
192
|
/** The merge itself. Runs with the config file's lock held. */
|
|
168
193
|
function mergeJsonConfig(abs, key, serverId, entry, opts) {
|
|
169
194
|
const { approved, dryRun = false, root } = opts;
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
195
|
+
const existed = existsSync(abs);
|
|
196
|
+
let raw = '';
|
|
197
|
+
if (existed) {
|
|
198
|
+
try {
|
|
199
|
+
raw = readFileSync(abs, 'utf8');
|
|
200
|
+
} catch (e) {
|
|
201
|
+
// An unreadable config is reported, never rewritten.
|
|
202
|
+
return { status: 'skipped', detail: `cannot read the config (${e.message}) — left untouched`, path: abs };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// A config that does not exist yet starts life as plain strict JSON; an
|
|
206
|
+
// existing one is merged in place, so a JSONC file keeps its comments.
|
|
207
|
+
const result = existed
|
|
208
|
+
? mergeEntry(raw, key, serverId, entry)
|
|
209
|
+
: { text: JSON.stringify(nestedConfig(key, serverId, entry), null, 2) + '\n', changed: true };
|
|
210
|
+
if (result.reason) {
|
|
211
|
+
// A file we cannot merge into is never rewritten.
|
|
212
|
+
return { status: 'skipped', detail: `${result.reason} — left untouched`, path: abs };
|
|
174
213
|
}
|
|
175
|
-
|
|
176
|
-
const bucket = obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key]) ? obj[key] : {};
|
|
177
|
-
const existing = bucket[serverId];
|
|
178
|
-
if (existing && JSON.stringify(existing) === JSON.stringify(entry)) {
|
|
214
|
+
if (!result.changed) {
|
|
179
215
|
return { status: 'unchanged', path: abs };
|
|
180
216
|
}
|
|
181
217
|
// Approval gate: without approval nothing is written.
|
|
182
218
|
if (dryRun || !approved) {
|
|
183
219
|
return { status: 'planned', detail: dryRun ? 'would write (dry-run)' : 'would write (needs approval)', path: abs };
|
|
184
220
|
}
|
|
185
|
-
bucket[serverId] = entry;
|
|
186
|
-
obj[key] = bucket;
|
|
187
221
|
try {
|
|
188
222
|
if (root) assertWithinRoot(root, abs);
|
|
189
223
|
assertNotSymlink(abs);
|
|
190
|
-
writeFileAtomic(abs,
|
|
224
|
+
writeFileAtomic(abs, result.text, { root });
|
|
191
225
|
} catch (e) {
|
|
192
226
|
return { status: 'error', detail: e.message, path: abs };
|
|
193
227
|
}
|
|
@@ -450,21 +484,30 @@ export function removeWiring(projectRoot, serverId, home = homedir(), location =
|
|
|
450
484
|
}
|
|
451
485
|
let next = start + 1;
|
|
452
486
|
while (next < lines.length && !/^\s*\[/.test(lines[next])) next++;
|
|
453
|
-
|
|
487
|
+
// A global config lives under the home directory, not the project —
|
|
488
|
+
// the same rule the JSON branch uses. With the project root here, the
|
|
489
|
+
// containment check REFUSED to uninstall a global Codex config.
|
|
490
|
+
const root = pathIsWithin(abs, projectRoot) ? projectRoot : home;
|
|
491
|
+
writeFileAtomic(abs, [...lines.slice(0, start), ...lines.slice(next)].join('\n'), { root });
|
|
454
492
|
removed.push(abs);
|
|
455
493
|
} else {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
494
|
+
// The removal is textual for a JSONC config: re-serializing the
|
|
495
|
+
// parsed document would delete the user's comments.
|
|
496
|
+
let raw;
|
|
497
|
+
try {
|
|
498
|
+
raw = readFileSync(abs, 'utf8');
|
|
499
|
+
} catch (e) {
|
|
500
|
+
skipped.push(`${abs} (${e.message})`);
|
|
459
501
|
continue;
|
|
460
502
|
}
|
|
461
|
-
const
|
|
462
|
-
if (!
|
|
463
|
-
skipped.push(`${abs} (entry not found)`);
|
|
503
|
+
const result = removeEntry(raw, entry.key, serverId);
|
|
504
|
+
if (!result.changed) {
|
|
505
|
+
skipped.push(`${abs} (${result.reason ?? 'entry not found'})`);
|
|
464
506
|
continue;
|
|
465
507
|
}
|
|
466
|
-
|
|
467
|
-
|
|
508
|
+
// A global config lives under the home directory, not the project.
|
|
509
|
+
const root = pathIsWithin(abs, projectRoot) ? projectRoot : home;
|
|
510
|
+
writeFileAtomic(abs, result.text, { root });
|
|
468
511
|
removed.push(abs);
|
|
469
512
|
}
|
|
470
513
|
} catch (e) {
|