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.
Files changed (42) hide show
  1. package/README.md +2 -6
  2. package/assets/briefs/investigate.md +16 -17
  3. package/assets/briefs/review.md +41 -29
  4. package/assets/briefs/survey.md +17 -20
  5. package/assets/briefs/verifier.md +52 -40
  6. package/assets/harness-mcp.json +6 -6
  7. package/assets/platform-capabilities.json +387 -71
  8. package/assets/references/artifact-templates.md +67 -232
  9. package/assets/references/interview.md +83 -199
  10. package/assets/references/mcp-and-skills.md +35 -125
  11. package/assets/references/orchestration.md +96 -209
  12. package/assets/references/platforms.md +41 -110
  13. package/assets/skill-registry.json +10 -1
  14. package/assets/templates/adr.md +3 -3
  15. package/assets/templates/architecture.yaml +31 -28
  16. package/assets/templates/edge-matrix.md +8 -8
  17. package/assets/templates/mermaid.md +3 -10
  18. package/assets/templates/prd.md +15 -15
  19. package/assets/templates/tasks.yaml +25 -18
  20. package/package.json +2 -2
  21. package/src/cli.mjs +1 -2
  22. package/src/context.mjs +12 -42
  23. package/src/core/brief-context.mjs +106 -0
  24. package/src/core/brief-scope.mjs +218 -0
  25. package/src/core/briefs.mjs +109 -1
  26. package/src/core/codegraph-manager.mjs +12 -0
  27. package/src/core/doc-navigator.mjs +101 -17
  28. package/src/core/errors.mjs +1 -1
  29. package/src/core/harness-wiring.mjs +65 -22
  30. package/src/core/jsonc-config.mjs +706 -0
  31. package/src/core/plan-grapher.mjs +11 -3
  32. package/src/core/skill-manager.mjs +181 -13
  33. package/src/core/skill-roots.mjs +92 -0
  34. package/src/core/tokens.mjs +78 -0
  35. package/src/core/worker-contract.mjs +145 -18
  36. package/src/server.mjs +0 -3
  37. package/src/tools/help.mjs +17 -34
  38. package/src/tools/plan.mjs +36 -22
  39. package/src/tools/skills.mjs +67 -5
  40. package/src/tools/tasks.mjs +44 -18
  41. package/src/core/ui-channel.mjs +0 -222
  42. 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
+ }
@@ -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(/<([a-z][a-z. ]*)>/g)].map((m) => m[1]))];
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
- const candidates = [join(repoRoot, filePart), join(slugDir, filePart)];
452
- const hit = candidates.find((p) => {
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
- if (!hit) throw notFound(`cannot read section ${sectionRef} (missing or not a file)`, { section: sectionRef });
460
- let hitReal;
461
- try {
462
- hitReal = realpathSync(hit);
463
- } catch {
464
- hitReal = resolve(hit);
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
- if (!hitReal.startsWith(slugDir + sep)) {
467
- throw invalidInput(`section ${sectionRef} is outside ${input}`, { section: sectionRef });
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
- const doc = parsedDocAt(hit);
470
- if (!doc) throw notFound(`cannot read section ${sectionRef} (unreadable)`, { section: sectionRef });
471
- if (anchor) {
472
- const section = findSection(doc, anchor);
473
- if (!section) throw notFound(`section ${sectionRef}: no section '${anchor}' in ${filePart}`, { section: sectionRef });
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(hit),
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
- return { mode, data: { path: relFromRoot(hit), text: doc.lines.join('\n') } };
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') {
@@ -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 (extension not running, binary missing). */
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
- /** Read + parse a JSON config; null when absent, {error} when unparseable. */
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
- return { data: JSON.parse(readFileSync(abs, 'utf8')), existed: true };
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 { data, existed, error } = readJsonConfig(abs);
171
- if (error) {
172
- // A file we cannot parse is never rewritten.
173
- return { status: 'skipped', detail: `not valid JSON (${error}) — left untouched`, path: abs };
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
- const obj = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
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, JSON.stringify(obj, null, 2) + '\n', { root });
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
- writeFileAtomic(abs, [...lines.slice(0, start), ...lines.slice(next)].join('\n'), { root: projectRoot });
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
- const { data } = readJsonConfig(abs);
457
- if (!data || typeof data !== 'object') {
458
- skipped.push(`${abs} (unreadable)`);
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 bucket = data[entry.key];
462
- if (!bucket || typeof bucket !== 'object' || !(serverId in bucket)) {
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
- delete bucket[serverId];
467
- writeFileAtomic(abs, JSON.stringify(data, null, 2) + '\n', { root: projectRoot });
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) {