archgen-mcp 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,7 @@
21
21
  // EVERY UI ACTION IS BEST-EFFORT: with no extension running the tool returns
22
22
  // `{available:false}` and the agent's work continues. UI control never
23
23
  // blocks a task.
24
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
24
+ import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
  import { randomUUID } from 'node:crypto';
27
27
  import { writeFileAtomic } from './fs-safety.mjs';
@@ -121,7 +121,8 @@ export async function sendUiCommand(projectRoot, toolAction, args = {}, opts = {
121
121
  expiresAt: new Date(Date.now() + CMD_TTL_MS).toISOString(),
122
122
  };
123
123
 
124
- writeFileAtomic(cmdPath, JSON.stringify(command, null, 2) + '\n');
124
+ writeFileAtomic(cmdPath, JSON.stringify(command, null, 2) + '\n', { root: projectRoot });
125
+ pruneQueue(runtime);
125
126
 
126
127
  const timeoutMs = Math.min(opts.timeoutMs ?? DEFAULT_ACK_TIMEOUT_MS, MAX_ACK_TIMEOUT_MS);
127
128
  const deadline = Date.now() + timeoutMs;
@@ -153,12 +154,62 @@ export async function sendUiCommand(projectRoot, toolAction, args = {}, opts = {
153
154
  };
154
155
  }
155
156
 
157
+ /**
158
+ * Prune the command and acknowledgement queues.
159
+ *
160
+ * The queues are a channel, not an archive. Every command the server writes
161
+ * is eventually acknowledged (or expires), and the extension leaves the file
162
+ * in place — so without a prune the directory grows for the life of the
163
+ * project, and every status call reads the whole directory.
164
+ *
165
+ * Entries are removed by AGE (anything older than the command TTL is dead
166
+ * either way) and the newest N are kept, so the queue is bounded by
167
+ * construction rather than by hope.
168
+ *
169
+ * @param {string} runtime the `.archgen/.runtime` directory
170
+ */
171
+ export function pruneQueue(runtime, { maxEntries = 200, maxAgeMs = CMD_TTL_MS * 4 } = {}) {
172
+ const cmdDir = join(runtime, 'cmd');
173
+ const ackDir = join(runtime, 'ack');
174
+ const now = Date.now();
175
+ for (const dir of [cmdDir, ackDir]) {
176
+ if (!existsSync(dir)) continue;
177
+ let names;
178
+ try {
179
+ names = readdirSync(dir).filter((n) => n.endsWith('.json'));
180
+ } catch {
181
+ continue;
182
+ }
183
+ const kept = [];
184
+ for (const name of names) {
185
+ const abs = join(dir, name);
186
+ try {
187
+ const age = now - statSync(abs).mtimeMs;
188
+ if (age > maxAgeMs) {
189
+ unlinkSync(abs);
190
+ continue;
191
+ }
192
+ kept.push({ abs, mtime: statSync(abs).mtimeMs });
193
+ } catch {
194
+ /* vanished between the listing and the stat */
195
+ }
196
+ }
197
+ kept.sort((a, b) => b.mtime - a.mtime);
198
+ for (const { abs } of kept.slice(maxEntries)) {
199
+ try {
200
+ unlinkSync(abs);
201
+ } catch {
202
+ /* another process pruned it first */
203
+ }
204
+ }
205
+ }
206
+ }
207
+
156
208
  /**
157
209
  * List pending (unacknowledged) commands — diagnostics.
158
210
  * @param {string} projectRoot
159
211
  */
160
- export function pendingUiCommands(projectRoot) {
161
- const dir = join(projectRoot, RUNTIME_DIR, 'cmd');
212
+ export function pendingUiCommands(projectRoot) { const dir = join(projectRoot, RUNTIME_DIR, 'cmd');
162
213
  if (!existsSync(dir)) return [];
163
214
  const out = [];
164
215
  for (const name of readdirSync(dir)) {
@@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs';
12
12
  import { parseYaml } from './yaml.mjs';
13
13
  import { buildGraph, findCycle, GraphError } from './graph.mjs';
14
14
  import { checkPlanCoverage } from './plan-coverage.mjs';
15
- import { invalidInput } from './errors.mjs';
15
+ import { invalidInput, ArchgenError } from './errors.mjs';
16
16
 
17
17
  export const STATUSES = ['pending', 'ready', 'running', 'blocked', 'done', 'failed'];
18
18
  export const REQUIRED = ['id', 'title', 'file_ownership'];
@@ -37,7 +37,20 @@ export function validateTasksYaml(file, opts = {}) {
37
37
  const errors = [];
38
38
  const warnings = [];
39
39
 
40
- const raw = readFileSync(file, 'utf8');
40
+ let raw;
41
+ try {
42
+ raw = readFileSync(file, 'utf8');
43
+ } catch (e) {
44
+ // A missing or unreadable file is NOT an internal error. The agent is
45
+ // told to branch on the code, and "the file is not there" is a fact it
46
+ // can act on — an INTERNAL tells it nothing and hides which path failed.
47
+ const code = e?.code === 'ENOENT' ? 'NOT_FOUND' : 'INVALID_INPUT';
48
+ throw new ArchgenError(
49
+ code,
50
+ `${code === 'NOT_FOUND' ? 'tasks file not found' : `cannot read tasks file`}: ${file}`,
51
+ { file },
52
+ );
53
+ }
41
54
  let data;
42
55
  try {
43
56
  ({ data } = parseYaml(raw, { filename: file }));
@@ -96,7 +109,7 @@ export function validateTasksYaml(file, opts = {}) {
96
109
 
97
110
  if (planDir) {
98
111
  // Delegate plan<->task coverage checks to the single shared implementation.
99
- for (const f of checkPlanCoverage(planDir, tasks)) errors.push(f);
112
+ for (const f of checkPlanCoverage(planDir, tasks).findings) errors.push(f);
100
113
  }
101
114
 
102
115
  return {
@@ -61,7 +61,7 @@ export function verifyPlan(file, planDir) {
61
61
  }
62
62
  }
63
63
 
64
- issues.push(...checkPlanCoverage(planDir, tasks));
64
+ issues.push(...checkPlanCoverage(planDir, tasks).findings);
65
65
  } catch (e) {
66
66
  if (e instanceof GraphError) issues.push(e.message);
67
67
  else issues.push(`internal check failure: ${e?.message ?? String(e)}`);
@@ -86,20 +86,14 @@ export function planCoverage(file, planDir) {
86
86
  return { covered: [], uncovered: [], findings: [`unparseable tasks.yaml: ${e.message}`] };
87
87
  }
88
88
  const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
89
- const findings = checkPlanCoverage(planDir, tasks);
89
+ const { findings, referenced, mentionedInTasks } = checkPlanCoverage(planDir, tasks);
90
90
  const covered = [];
91
91
  const uncovered = [];
92
92
  for (const t of tasks) {
93
93
  const id = String(t?.id ?? '');
94
94
  if (!id) continue;
95
- const mentioned = findings.some((f) => f.includes(id)) || tasks.some((x) => x?.id !== id && mentions(x, id));
95
+ const mentioned = referenced.has(id) || mentionedInTasks.has(id);
96
96
  (mentioned ? covered : uncovered).push(id);
97
97
  }
98
98
  return { covered, uncovered, findings };
99
99
  }
100
-
101
- /** Does any task reference `id` in its artifacts / acceptance text? */
102
- function mentions(task, id) {
103
- const hay = JSON.stringify([task?.artifacts ?? [], task?.acceptance ?? [], task?.title ?? '']);
104
- return hay.includes(id);
105
- }
@@ -13,7 +13,7 @@
13
13
  import { readFileSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
  import { parseYaml } from './yaml.mjs';
16
- import { buildGraph } from './graph.mjs';
16
+ import { buildGraph, GraphError } from './graph.mjs';
17
17
  import { invalidInput, notFound } from './errors.mjs';
18
18
 
19
19
  /**
@@ -50,7 +50,21 @@ export function buildWorkerContract(opts) {
50
50
 
51
51
  // The dependency neighbourhood — what must exist BEFORE this task, and what
52
52
  // waits on it. Derived from depends_on, never hand-labelled.
53
- const { byId, prerequisites, dependents } = buildGraph(tasks);
53
+ //
54
+ // A broken graph (a duplicate id, a dangling depends_on) is a PLAN defect,
55
+ // not an internal error. The agent branches on the code, so it is mapped
56
+ // to the same typed codes the other plan tools use.
57
+ let byId;
58
+ let prerequisites;
59
+ let dependents;
60
+ try {
61
+ ({ byId, prerequisites, dependents } = buildGraph(tasks));
62
+ } catch (e) {
63
+ if (e instanceof GraphError) {
64
+ throw invalidInput(`${tasksYaml}: ${e.message}`, { file: tasksYaml });
65
+ }
66
+ throw e;
67
+ }
54
68
  const upstream = (prerequisites.get(taskId) ?? []).map((id) => ({ id, title: byId.get(id)?.title ?? null }));
55
69
  const downstream = (dependents.get(taskId) ?? []).map((id) => ({ id, title: byId.get(id)?.title ?? null }));
56
70
 
package/src/core/yaml.mjs CHANGED
@@ -105,7 +105,7 @@ function peekContent(lines, pos, indent) {
105
105
  if (c.type === 'blank') { i++; continue; }
106
106
  if (c.type === 'comment') { i++; continue; } // handled by caller via pendingComments
107
107
  if (c.indent < indent) return null;
108
- if (c.indent > indent) fail({ filename: 'internal' }, i + 1, `unexpected deeper indentation (got ${c.indent}, expected ${indent})`);
108
+ if (c.indent > indent) fail(ctx, i + 1, `unexpected deeper indentation (got ${c.indent}, expected ${indent})`);
109
109
  return { idx: i, line: c };
110
110
  }
111
111
  return null;
@@ -432,6 +432,31 @@ function samePath(a, b) {
432
432
  return true;
433
433
  }
434
434
 
435
+ /**
436
+ * Encode a mapping key.
437
+ *
438
+ * Keys are written the way VALUES are written: quoted whenever a plain key
439
+ * would be misread. A key containing ": ", starting with a YAML indicator,
440
+ * or looking like a number or a keyword is quoted, so the file the emitter
441
+ * writes always parses back to the key it was given.
442
+ *
443
+ * Without this, a key like `odd: key` was emitted verbatim and produced a
444
+ * file that no longer parsed — and a single `set_status` could destroy the
445
+ * plan it was updating.
446
+ */
447
+ function encodeKey(k) {
448
+ const s = String(k);
449
+ if (s === '') return '""';
450
+ if (
451
+ /^[A-Za-z_][A-Za-z0-9_-]*$/.test(s) &&
452
+ !/^(true|false|null|yes|no|on|off)$/i.test(s) &&
453
+ !/^-?\d+(\.\d+)?$/.test(s)
454
+ ) {
455
+ return s;
456
+ }
457
+ return JSON.stringify(s);
458
+ }
459
+
435
460
  function emitValue(out, v, indent, comments, used, path) {
436
461
  const pad = ' '.repeat(indent);
437
462
  if (Array.isArray(v)) {
@@ -450,7 +475,7 @@ function emitValue(out, v, indent, comments, used, path) {
450
475
  const [k0, v0] = entries[0];
451
476
  const k0path = [...itemPath, k0];
452
477
  preComments(out, comments, used, k0path);
453
- out.push(pad + '- ' + k0 + ': ' + encodeScalar(v0));
478
+ out.push(pad + '- ' + encodeKey(k0) + ': ' + encodeScalar(v0));
454
479
  inlineCommentFor(out, comments, used, k0path);
455
480
  emitEntries(out, entries.slice(1), indent + INDENT_UNIT, comments, used, itemPath);
456
481
  } else {
@@ -473,13 +498,13 @@ function emitEntries(out, entries, indent, comments, used, basePath) {
473
498
  const kpath = [...basePath, k];
474
499
  preComments(out, comments, used, kpath);
475
500
  if (val !== null && typeof val === 'object') {
476
- if (Object.keys(val).length === 0 && !Array.isArray(val)) { out.push(pad + k + ': {}'); inlineCommentFor(out, comments, used, kpath); continue; }
477
- if (Array.isArray(val) && val.length === 0) { out.push(pad + k + ': []'); inlineCommentFor(out, comments, used, kpath); continue; }
478
- out.push(pad + k + ':');
501
+ if (Object.keys(val).length === 0 && !Array.isArray(val)) { out.push(pad + encodeKey(k) + ': {}'); inlineCommentFor(out, comments, used, kpath); continue; }
502
+ if (Array.isArray(val) && val.length === 0) { out.push(pad + encodeKey(k) + ': []'); inlineCommentFor(out, comments, used, kpath); continue; }
503
+ out.push(pad + encodeKey(k) + ':');
479
504
  inlineCommentFor(out, comments, used, kpath);
480
505
  emitValue(out, val, indent + INDENT_UNIT, comments, used, kpath);
481
506
  } else {
482
- out.push(pad + k + ': ' + encodeScalar(val));
507
+ out.push(pad + encodeKey(k) + ': ' + encodeScalar(val));
483
508
  inlineCommentFor(out, comments, used, kpath);
484
509
  }
485
510
  }
@@ -82,14 +82,15 @@ export function registerTool(server, name, spec) {
82
82
  },
83
83
  },
84
84
  async (args, extra) => {
85
- // A tool may target a project other than the server's cwd (a monorepo
86
- // package, a worktree). The projectRoot argument wins; otherwise the
87
- // server-level context applies.
85
+ // A tool may target a directory inside the project (a monorepo package,
86
+ // a worktree under it). The projectRoot argument SELECTS one; it does
87
+ // not define the boundary. The server's anchor does, and a selection
88
+ // outside it is refused with PERMISSION_DENIED.
88
89
  const base = extra?.archgenContext ?? serverContext();
89
90
  const requested = args?.projectRoot;
90
91
  const ctx =
91
92
  typeof requested === 'string' && requested.trim() !== ''
92
- ? new Context({ projectRoot: requested, env: base.env, home: base.home })
93
+ ? base.withProjectRoot(requested)
93
94
  : base;
94
95
  const envelope = await guard(spec.handler, ctx, args ?? {});
95
96
  return toToolResult(envelope);
@@ -16,7 +16,6 @@ const schema = z.object({
16
16
  location: z.enum(['project', 'global', 'both']).optional().describe('Which scope to wire. Defaults to both.'),
17
17
  approve: z.boolean().optional().describe('Required to write any MCP config. Without it you get the plan and nothing is written.'),
18
18
  telemetry: z.enum(['keep', 'off', 'on']).optional().describe('Telemetry preference. Defaults to keep (leave as-is).'),
19
- noUpgrade: z.boolean().optional().describe('Do not upgrade an existing CodeGraph CLI.'),
20
19
  noInstall: z.boolean().optional().describe('Do not install the CLI; use the npx fallback in configs.'),
21
20
  projectRoot: projectRootArg,
22
21
  });
@@ -45,7 +44,6 @@ export function registerCodegraphTools(server) {
45
44
  location,
46
45
  approved: args.approve === true,
47
46
  telemetry: args.telemetry ?? 'keep',
48
- noUpgrade: args.noUpgrade === true,
49
47
  noInstall: args.noInstall === true,
50
48
  home: ctx.home,
51
49
  env: ctx.env,
@@ -37,20 +37,20 @@ export function registerDocTools(server) {
37
37
  const slugDir = resolveSlugDir(ctx, args.slugDir);
38
38
  switch (args.action) {
39
39
  case 'index':
40
- return docIndex(slugDir, { mode: 'overview', file: args.file ?? null });
40
+ return docIndex(slugDir, { mode: 'overview', file: args.file ?? null, projectRoot: ctx.projectRoot });
41
41
  case 'digest':
42
- return docIndex(slugDir, { mode: 'digest', file: args.file ?? null });
42
+ return docIndex(slugDir, { mode: 'digest', file: args.file ?? null, projectRoot: ctx.projectRoot });
43
43
  case 'validate':
44
- return docIndex(slugDir, { mode: 'validate', file: args.file ?? null });
44
+ return docIndex(slugDir, { mode: 'validate', file: args.file ?? null, projectRoot: ctx.projectRoot });
45
45
  case 'refs':
46
46
  if (!args.token) throw invalidInput('refs requires a token', { action: args.action });
47
- return docIndex(slugDir, { mode: 'refs', token: args.token, file: args.file ?? null });
47
+ return docIndex(slugDir, { mode: 'refs', token: args.token, file: args.file ?? null, projectRoot: ctx.projectRoot });
48
48
  case 'map':
49
49
  if (!args.token) throw invalidInput('map requires a token', { action: args.action });
50
- return docIndex(slugDir, { mode: 'map', token: args.token, file: args.file ?? null });
50
+ return docIndex(slugDir, { mode: 'map', token: args.token, file: args.file ?? null, projectRoot: ctx.projectRoot });
51
51
  case 'section':
52
52
  if (!args.section) throw invalidInput('section requires a section path', { action: args.action });
53
- return docIndex(slugDir, { mode: 'section', section: args.section, file: args.file ?? null });
53
+ return docIndex(slugDir, { mode: 'section', section: args.section, file: args.file ?? null, projectRoot: ctx.projectRoot });
54
54
  default:
55
55
  throw invalidInput(`unknown action '${args.action}'`, { action: args.action });
56
56
  }
@@ -134,7 +134,7 @@ Installs, wires and indexes CodeGraph — the source-symbol index — and its ow
134
134
  **Actions**
135
135
  | action | what it does | required args |
136
136
  |---|---|---|
137
- | \`ensure\` | install/upgrade the CLI + wire the MCP + build the index | projectRoot?, approve? |
137
+ | \`ensure\` | install the CLI if missing + wire the MCP + build the index | projectRoot?, approve? |
138
138
  | \`status\` | what is installed / wired / indexed (read-only) | projectRoot? |
139
139
  | \`wire\` | wire the MCP into detected harnesses only | projectRoot?, approve? |
140
140
 
@@ -6,9 +6,9 @@
6
6
  // "which one do I call?" confusion the whole MCP exercise exists to fix.
7
7
  import { z } from 'zod/v4';
8
8
  import { registerTool, projectRootArg } from './_register.mjs';
9
- import { loadAsset, BRIEFS_DIR, TEMPLATES_DIR } from '../context.mjs';
9
+ import { loadAsset, BRIEFS_DIR, TEMPLATES_DIR, serverVersion } from '../context.mjs';
10
10
  import { buildScaffold } from '../core/scaffold.mjs';
11
- import { loadBrief, BRIEF_KINDS } from '../core/briefs.mjs';
11
+ import { loadBrief, remainingPlaceholders, BRIEF_KINDS } from '../core/briefs.mjs';
12
12
  import { validateTasksYaml } from '../core/validator.mjs';
13
13
  import { verifyPlan, planCoverage } from '../core/verifier.mjs';
14
14
  import { planGraph } from '../core/plan-grapher.mjs';
@@ -33,8 +33,14 @@ const schema = z.object({
33
33
  status: z.boolean().optional().describe('Include status styling (graph, mermaid only).'),
34
34
  module: z.string().optional().describe('Restrict the graph to one module (graph).'),
35
35
  scope: z.enum(['SMALL', 'MEDIUM', 'LARGE']).optional().describe('Scope class (scaffold).'),
36
- slug: z.string().optional().describe('Project slug (scaffold).'),
36
+ slug: z.string().optional().describe('Project slug (scaffold, brief).'),
37
37
  kind: z.enum(BRIEF_KINDS).optional().describe('Which dispatch brief (brief).'),
38
+ featureRequest: z
39
+ .string()
40
+ .optional()
41
+ .describe('The feature being requested, in one line (brief). Defaults to the slug.'),
42
+ topic: z.string().optional().describe('Investigation topic (brief).'),
43
+ round: z.number().int().positive().optional().describe('Investigation round number (brief).'),
38
44
  projectRoot: projectRootArg,
39
45
  });
40
46
 
@@ -78,14 +84,44 @@ export function registerPlanTools(server) {
78
84
  return buildScaffold(loadAsset('depth-ladder.json'), args.scope, {
79
85
  slug: args.slug ?? null,
80
86
  templatesDir: TEMPLATES_DIR,
87
+ // The generator token carries the RUNNING server's version. The
88
+ // server knows it exactly, so the artifact records the truth
89
+ // instead of the agent having to guess, copy or type a version —
90
+ // and no release ever has to update a version written by hand.
91
+ generator: `archgen v${serverVersion()}`,
81
92
  });
82
93
  }
83
94
  case 'brief': {
84
95
  if (!args.kind) throw invalidInput('brief requires kind', { action });
85
- return loadBrief(BRIEFS_DIR, args.kind, {
86
- 'feature request': args.slug ?? '<feature request>',
87
- 'slug dir': args.slug ?? '.archgen/<slug>',
88
- });
96
+ // Every placeholder the chosen brief uses is filled in, derived
97
+ // from ONE source (the slug) so the paths in a brief agree with
98
+ // each other. Previously only two of the seven were substituted,
99
+ // and the 'slug dir' one was given the bare slug — so a worker
100
+ // was told to read `checkout` and to write into `.archgen/<slug>/`
101
+ // with the placeholder still in it.
102
+ const slug = args.slug ?? null;
103
+ const vars = {};
104
+ if (slug) {
105
+ vars['slug'] = slug;
106
+ vars['slug dir'] = `.archgen/${slug}`;
107
+ vars['plans dir'] = `.archgen/${slug}/plans`;
108
+ vars['tasks.yaml'] = args.tasksYaml ?? `.archgen/${slug}/tasks.yaml`;
109
+ }
110
+ if (args.featureRequest) vars['feature request'] = args.featureRequest;
111
+ else if (slug) vars['feature request'] = slug;
112
+ if (args.topic) vars['topic'] = args.topic;
113
+ if (args.round !== undefined) vars['round'] = String(args.round);
114
+ const { kind, brief } = loadBrief(BRIEFS_DIR, args.kind, vars);
115
+ // A brief with a placeholder still in it is a brief that would
116
+ // send a worker looking for a directory named "<slug dir>".
117
+ const missing = remainingPlaceholders(brief);
118
+ if (missing.length) {
119
+ throw invalidInput(
120
+ `brief '${kind}' needs ${missing.join(', ')} — pass slug${missing.includes('topic') ? ', topic' : ''}${missing.includes('round') ? ', round' : ''}`,
121
+ { action, kind, missing },
122
+ );
123
+ }
124
+ return { kind, brief };
89
125
  }
90
126
  case 'graph': {
91
127
  const input = args.tasksYaml ?? args.planDir;
@@ -102,6 +138,7 @@ export function registerPlanTools(server) {
102
138
  mermaid: format === 'mermaid',
103
139
  status: args.status === true,
104
140
  module: args.module ?? null,
141
+ projectRoot: ctx.projectRoot,
105
142
  });
106
143
  }
107
144
  default:
@@ -2,12 +2,11 @@
2
2
  import { z } from 'zod/v4';
3
3
  import { registerTool, projectRootArg } from './_register.mjs';
4
4
  import { loadAsset } from '../context.mjs';
5
- import { wireArchgenMcp, archgenMcpStatus, resolveMcpBinary } from '../core/mcp-server-manager.mjs';
5
+ import { wireArchgenMcp, archgenMcpStatus, resolveMcpBinary, uninstallArchgenMcp } from '../core/mcp-server-manager.mjs';
6
6
  import { manageCodegraph, assertLocation, findCodegraph } from '../core/codegraph-manager.mjs';
7
7
  import { detectHarnesses } from '../core/harness-wiring.mjs';
8
8
  import { platformCapabilities, detectHarness } from '../core/platform-caps.mjs';
9
9
  import { checkCapability } from '../core/compat.mjs';
10
- '../core/errors.mjs';
11
10
 
12
11
  const ACTION_DESC =
13
12
  'doctor | bootstrap | install_mcp | uninstall_mcp | harnesses | harness_info — see archgen_help.';
@@ -127,7 +126,12 @@ export function registerSetupTools(server) {
127
126
  if (!approved) {
128
127
  return { written: false, planned: true, detail: 'uninstall_mcp needs approve: true — nothing was removed' };
129
128
  }
130
- return { written: true, ...uninstallArchgenMcp(ctx.projectRoot, ctx.home) };
129
+ // The scope is honoured: removing only the global wiring leaves the
130
+ // project's wiring in place, and vice versa.
131
+ return {
132
+ written: true,
133
+ ...uninstallArchgenMcp(ctx.projectRoot, ctx.home, location),
134
+ };
131
135
  }
132
136
 
133
137
  if (args.action === 'install_mcp') {
@@ -11,8 +11,30 @@ import { loadAsset } from '../context.mjs';
11
11
  import { setTaskStatus, STATUSES } from '../core/status-mutator.mjs';
12
12
  import { writeResumeSnapshot } from '../core/resume-snapshot.mjs';
13
13
  import { invalidInput } from '../core/errors.mjs';
14
+ import { resolveSlugInput, ScopeError } from '../core/scope.mjs';
14
15
  import { dirname, join } from 'node:path';
15
16
 
17
+ /**
18
+ * The `.archgen/<slug>/` directory a tasks file belongs to.
19
+ *
20
+ * A tasks.yaml outside the artifact space has no slug directory, so there is
21
+ * nowhere the snapshot may be written — and the worker contract says the
22
+ * snapshot is NEVER created outside `.archgen/`.
23
+ */
24
+ function slugDirFor(tasksFile, action) {
25
+ try {
26
+ return resolveSlugInput(tasksFile).root;
27
+ } catch (e) {
28
+ if (e instanceof ScopeError) {
29
+ throw invalidInput(
30
+ `tasks.yaml must live in .archgen/<slug>/ — ${tasksFile} does not`,
31
+ { action, file: tasksFile },
32
+ );
33
+ }
34
+ throw e;
35
+ }
36
+ }
37
+
16
38
  const ACTION_DESC = 'next | set_status | list | snapshot | contract — see archgen_help.';
17
39
 
18
40
  const schema = z.object({
@@ -60,7 +82,11 @@ export function registerTaskTools(server) {
60
82
  case 'snapshot': {
61
83
  if (!args.tasksYaml) throw invalidInput('tasksYaml is required for snapshot', { action });
62
84
  const tasksFile = ctx.contain(args.tasksYaml);
63
- const slugDir = dirname(tasksFile);
85
+ // The snapshot belongs to the artifact space: `<slug>/.agent/`.
86
+ // Deriving it from the tasks file's PARENT wrote it wherever that
87
+ // file happened to be — `<project>/plans/.agent/resume.md`, outside
88
+ // `.archgen/`, which is exactly what the worker contract forbids.
89
+ const slugDir = slugDirFor(tasksFile, action);
64
90
  const waves = resolveWaves(tasksFile).waves;
65
91
  return writeResumeSnapshot(slugDir, waves);
66
92
  }
@@ -101,7 +127,7 @@ export function registerTaskTools(server) {
101
127
  // the whole plan.
102
128
  let docSections = [];
103
129
  try {
104
- const map = docIndex(slugDir, { mode: 'map', token: args.taskId });
130
+ const map = docIndex(slugDir, { mode: 'map', token: args.taskId, projectRoot: ctx.projectRoot });
105
131
  docSections = (map.data.sections ?? []).slice(0, 2).map((s) => ({
106
132
  file: s.file,
107
133
  section: s.section,
@@ -116,9 +142,16 @@ export function registerTaskTools(server) {
116
142
  // The code-standards reference: resolved from the INSTALLED skill when
117
143
  // the caller does not name it. The worker needs a real path, and the
118
144
  // agent should not have to guess where the skill lives.
119
- const codeStandardsPath =
120
- args.codeStandardsPath ??
121
- (ctx.skill ? join(ctx.skill.path, 'references', 'code-standards.md') : null);
145
+ //
146
+ // A caller-supplied path is CONTAINED, exactly like every other
147
+ // path argument: it ends up verbatim in a prompt a worker reads,
148
+ // so an uncontained one would point the worker anywhere on the
149
+ // machine.
150
+ const codeStandardsPath = args.codeStandardsPath
151
+ ? ctx.contain(args.codeStandardsPath)
152
+ : ctx.skill
153
+ ? join(ctx.skill.path, 'references', 'code-standards.md')
154
+ : null;
122
155
 
123
156
  const contract = buildWorkerContract({
124
157
  tasksYaml: tasksFile,