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.
@@ -17,11 +17,11 @@
17
17
  // - The per-platform file path + top-level key live in assets/harness-mcp.json
18
18
  // — one source of truth, shared by the tool, the CLI and the docs.
19
19
  // - Writes are atomic and refuse to go through a symlink.
20
- import { existsSync, readFileSync } from 'node:fs';
20
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
21
21
  import { homedir } from 'node:os';
22
- import { join } from 'node:path';
23
- import { assertNotSymlink, assertWithinRoot, writeFileAtomic } from './fs-safety.mjs';
24
- './errors.mjs';
22
+ import { join, resolve, sep } from 'node:path';
23
+ import { assertNotSymlink, assertWithinRoot, withFileLock, writeFileAtomic } from './fs-safety.mjs';
24
+ import { invalidInput } from './errors.mjs';
25
25
 
26
26
  /**
27
27
  * Detect which harnesses are present in this environment.
@@ -45,6 +45,28 @@ export function expandHome(p, home = homedir()) {
45
45
  return p.startsWith('~/') ? join(home, p.slice(2)) : p;
46
46
  }
47
47
 
48
+ /**
49
+ * True when `abs` resolves inside `root`.
50
+ *
51
+ * The path is compared lexically after normalization, and symlinks are
52
+ * resolved on both sides when they exist — a symlink pointing out of the
53
+ * root must not pass as "inside".
54
+ */
55
+ export function pathIsWithin(abs, root) {
56
+ if (!abs || !root) return false;
57
+ const resolvedRoot = resolve(root);
58
+ const resolved = resolve(abs);
59
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + sep)) return false;
60
+ try {
61
+ const realRoot = realpathSync(resolvedRoot);
62
+ const real = realpathSync(resolved);
63
+ return real === realRoot || real.startsWith(realRoot + sep);
64
+ } catch {
65
+ // The path does not exist yet: the lexical check above stands.
66
+ return true;
67
+ }
68
+ }
69
+
48
70
  /**
49
71
  * Build the server entry for a harness, substituting the resolved binary path.
50
72
  *
@@ -57,12 +79,53 @@ export function expandHome(p, home = homedir()) {
57
79
  * @param {string|null} binPath the resolved absolute binary path, or null for the npx form
58
80
  * @param {string} fallbackCommand the command used when binPath is null
59
81
  */
82
+ /**
83
+ * Build the entry for one harness config.
84
+ *
85
+ * THE TWO FORMS ARE DIFFERENT COMMANDS, NOT ONE COMMAND WITH A SWAPPED
86
+ * EXECUTABLE.
87
+ *
88
+ * installed the server's own binary: `codegraph serve --mcp`
89
+ * npx the same server, fetched: `npx -y @colbymchenry/codegraph serve --mcp`
90
+ *
91
+ * When the binary was resolved, the installed form is used and its
92
+ * executable is replaced with the resolved absolute path. When it was NOT,
93
+ * the npx form is used VERBATIM — its executable is already `npx`, and
94
+ * rewriting it to the server's own name produced the nonsense command
95
+ * `codegraph -y @colbymchenry/codegraph serve --mcp`, which can never start.
96
+ *
97
+ * @param {any} entries the entries block (entries / archgenEntries)
98
+ * @param {any} npxEntries the npx fallback block (npxEntries / archgenNpxEntries)
99
+ * @param {string} entryKey the entry shape ('stdio' | 'stdioNoType' | …)
100
+ * @param {string|null} binPath the resolved absolute binary path, or null for the npx form
101
+ * @param {string} fallbackCommand the command used when no npx form exists
102
+ * @returns {any} a fresh entry object (the source is never mutated)
103
+ */
60
104
  export function buildEntry(entries, npxEntries, entryKey, binPath, fallbackCommand) {
61
- const source = binPath ? entries[entryKey] : (npxEntries?.[entryKey] ?? entries[entryKey]);
62
- const entry = JSON.parse(JSON.stringify(source));
63
- const command = binPath ?? fallbackCommand;
64
- if (Array.isArray(entry.command)) entry.command = [command, ...entry.command.slice(1)];
65
- else entry.command = command;
105
+ const installed = entries?.[entryKey];
106
+ const npx = npxEntries?.[entryKey];
107
+ const useNpx = !binPath && Boolean(npx);
108
+ const source = useNpx ? npx : installed;
109
+
110
+ if (!source || typeof source !== 'object') {
111
+ // A registry entry naming a shape the writer does not know is a REGISTRY
112
+ // bug, not a user error. Say so plainly instead of crashing with a
113
+ // SyntaxError from JSON.parse(undefined).
114
+ throw invalidInput(
115
+ `unknown MCP entry shape '${entryKey}' — the harness registry and the writer disagree`,
116
+ { entryKey, available: Object.keys(entries ?? {}) },
117
+ );
118
+ }
119
+
120
+ const entry = structuredClone(source);
121
+ if (binPath) {
122
+ if (Array.isArray(entry.command)) entry.command = [binPath, ...entry.command.slice(1)];
123
+ else entry.command = binPath;
124
+ } else if (!useNpx) {
125
+ // No npx form for this shape: fall back to the server's own name.
126
+ if (Array.isArray(entry.command)) entry.command = [fallbackCommand, ...entry.command.slice(1)];
127
+ else entry.command = fallbackCommand;
128
+ }
66
129
  return entry;
67
130
  }
68
131
 
@@ -79,6 +142,12 @@ export function readJsonConfig(abs) {
79
142
  /**
80
143
  * Merge a server entry into a JSON MCP config.
81
144
  *
145
+ * The read-merge-write runs under an exclusive lock on the config file. The
146
+ * write itself is atomic, but that only stops a reader seeing a half-written
147
+ * file — two writers still lose each other's work: one merges the archgen
148
+ * entry, the other merges the codegraph entry, and whichever lands last
149
+ * discards the first.
150
+ *
82
151
  * @param {string} abs absolute config path
83
152
  * @param {string} key the top-level key ('mcpServers' | 'servers' | 'mcp' | 'context_servers')
84
153
  * @param {string} serverId the entry name inside the bucket ('codegraph' | 'archgen')
@@ -87,6 +156,16 @@ export function readJsonConfig(abs) {
87
156
  * @returns {{status:'written'|'unchanged'|'skipped'|'planned'|'error', detail?:string, path?:string}}
88
157
  */
89
158
  export function writeJsonConfig(abs, key, serverId, entry, opts) {
159
+ const { approved, dryRun = false, root } = opts;
160
+ try {
161
+ return withFileLock(abs, () => mergeJsonConfig(abs, key, serverId, entry, opts));
162
+ } catch (e) {
163
+ return { status: 'error', detail: e.message, path: abs };
164
+ }
165
+ }
166
+
167
+ /** The merge itself. Runs with the config file's lock held. */
168
+ function mergeJsonConfig(abs, key, serverId, entry, opts) {
90
169
  const { approved, dryRun = false, root } = opts;
91
170
  const { data, existed, error } = readJsonConfig(abs);
92
171
  if (error) {
@@ -108,7 +187,7 @@ export function writeJsonConfig(abs, key, serverId, entry, opts) {
108
187
  try {
109
188
  if (root) assertWithinRoot(root, abs);
110
189
  assertNotSymlink(abs);
111
- writeFileAtomic(abs, JSON.stringify(obj, null, 2) + '\n');
190
+ writeFileAtomic(abs, JSON.stringify(obj, null, 2) + '\n', { root });
112
191
  } catch (e) {
113
192
  return { status: 'error', detail: e.message, path: abs };
114
193
  }
@@ -155,6 +234,15 @@ export function entryToToml(entry) {
155
234
  * @param {{approved: boolean, dryRun?: boolean, root?: string}} opts
156
235
  */
157
236
  export function writeTomlConfig(abs, serverId, entry, opts) {
237
+ try {
238
+ return withFileLock(abs, () => mergeTomlConfig(abs, serverId, entry, opts));
239
+ } catch (e) {
240
+ return { status: 'error', detail: e.message, path: abs };
241
+ }
242
+ }
243
+
244
+ /** The merge itself. Runs with the config file's lock held. */
245
+ function mergeTomlConfig(abs, serverId, entry, opts) {
158
246
  const { approved, dryRun = false, root } = opts;
159
247
  const existed = existsSync(abs);
160
248
  let raw = '';
@@ -197,7 +285,7 @@ export function writeTomlConfig(abs, serverId, entry, opts) {
197
285
  try {
198
286
  if (root) assertWithinRoot(root, abs);
199
287
  assertNotSymlink(abs);
200
- writeFileAtomic(abs, out);
288
+ writeFileAtomic(abs, out, { root });
201
289
  } catch (e) {
202
290
  return { status: 'error', detail: e.message, path: abs };
203
291
  }
@@ -220,6 +308,7 @@ export function writeTomlConfig(abs, serverId, entry, opts) {
220
308
  * binPath?: string|null,
221
309
  * fallbackCommand?: string,
222
310
  * skipHarnesses?: string[],
311
+ * targets?: string[]|null,
223
312
  * }} opts
224
313
  * @returns {{detected: string[], wired: Array<Record<string, unknown>>, skipped: Array<Record<string, unknown>>, planned: boolean}}
225
314
  */
@@ -237,14 +326,19 @@ export function wireServer(opts) {
237
326
  binPath = null,
238
327
  fallbackCommand = serverId,
239
328
  skipHarnesses = [],
329
+ targets = null,
240
330
  } = opts;
241
331
 
242
332
  const detected = detectHarnesses(registry, projectRoot, home);
333
+ // A caller that narrowed the set of harnesses must get exactly that set.
334
+ // The native path honoured the filter and this one did not, so a request
335
+ // for two harnesses wired ten.
336
+ const selected = targets ? detected.filter((d) => targets.includes(d.id)) : detected;
243
337
  const wantedLocations = location === 'both' ? ['global', 'project'] : [location];
244
338
  const wired = [];
245
339
  const skipped = [];
246
340
 
247
- for (const d of detected) {
341
+ for (const d of selected) {
248
342
  if (skipHarnesses.includes(d.id)) {
249
343
  skipped.push({ harness: d.id, reason: 'skipped by request' });
250
344
  continue;
@@ -262,12 +356,17 @@ export function wireServer(opts) {
262
356
  cfg.format === 'toml'
263
357
  ? writeTomlConfig(absPath, serverId, entry, { approved, dryRun, root })
264
358
  : writeJsonConfig(absPath, cfg.key, serverId, entry, { approved, dryRun, root });
265
- wired.push({ harness: d.id, location: loc, path: absPath, key: cfg.key ?? `mcp_servers.${serverId}`, ...res });
359
+ // The key recorded here is the one the writer ACTUALLY used. A JSON
360
+ // config uses the registry's top-level key; a TOML config's section is
361
+ // derived from the server id. Recording the registry's key instead made
362
+ // uninstall search for the wrong section and leave the real one behind.
363
+ const writtenKey = cfg.format === 'toml' ? `mcp_servers.${serverId}` : cfg.key;
364
+ wired.push({ harness: d.id, location: loc, path: absPath, key: writtenKey, ...res });
266
365
  }
267
366
  }
268
367
 
269
368
  return {
270
- detected: detected.map((d) => d.id),
369
+ detected: selected.map((d) => d.id),
271
370
  wired,
272
371
  skipped,
273
372
  planned: !approved && !dryRun && wired.some((w) => w.status === 'planned'),
@@ -291,7 +390,7 @@ export function recordWiring(projectRoot, serverId, entries) {
291
390
  }
292
391
  existing.servers[serverId] = { writtenAt: new Date().toISOString(), entries };
293
392
  try {
294
- writeFileAtomic(p, JSON.stringify(existing, null, 2) + '\n');
393
+ writeFileAtomic(p, JSON.stringify(existing, null, 2) + '\n', { root: projectRoot });
295
394
  } catch {
296
395
  /* the manifest is a convenience, never a blocker */
297
396
  }
@@ -302,9 +401,10 @@ export function recordWiring(projectRoot, serverId, entries) {
302
401
  * @param {string} projectRoot
303
402
  * @param {string} serverId
304
403
  * @param {string} [home]
404
+ * @param {'project'|'global'|'both'} [location] which scope to remove
305
405
  * @returns {{removed: string[], skipped: string[]}}
306
406
  */
307
- export function removeWiring(projectRoot, serverId, home = homedir()) {
407
+ export function removeWiring(projectRoot, serverId, home = homedir(), location = 'both') {
308
408
  const p = join(projectRoot, '.archgen', '.mcp-wiring.json');
309
409
  let manifest = null;
310
410
  try {
@@ -320,6 +420,20 @@ export function removeWiring(projectRoot, serverId, home = homedir()) {
320
420
  for (const entry of record.entries) {
321
421
  try {
322
422
  const abs = entry.path;
423
+ // The scope is honoured: a request to remove the global wiring leaves
424
+ // the project's wiring in place.
425
+ if (location !== 'both' && entry.location && entry.location !== location) {
426
+ skipped.push(`${abs} (${entry.location} scope)`);
427
+ continue;
428
+ }
429
+ // The manifest is a file ON DISK in the project — repository content.
430
+ // Its paths are therefore untrusted: a clone could ship a manifest
431
+ // naming any file on the machine. A recorded path must resolve inside
432
+ // the project root or the user's home, or it is refused.
433
+ if (!pathIsWithin(abs, projectRoot) && !pathIsWithin(abs, home)) {
434
+ skipped.push(`${abs} (outside the project and home directories)`);
435
+ continue;
436
+ }
323
437
  if (!existsSync(abs)) {
324
438
  skipped.push(`${entry.path} (absent)`);
325
439
  continue;
@@ -336,7 +450,7 @@ export function removeWiring(projectRoot, serverId, home = homedir()) {
336
450
  }
337
451
  let next = start + 1;
338
452
  while (next < lines.length && !/^\s*\[/.test(lines[next])) next++;
339
- writeFileAtomic(abs, [...lines.slice(0, start), ...lines.slice(next)].join('\n'));
453
+ writeFileAtomic(abs, [...lines.slice(0, start), ...lines.slice(next)].join('\n'), { root: projectRoot });
340
454
  removed.push(abs);
341
455
  } else {
342
456
  const { data } = readJsonConfig(abs);
@@ -350,7 +464,7 @@ export function removeWiring(projectRoot, serverId, home = homedir()) {
350
464
  continue;
351
465
  }
352
466
  delete bucket[serverId];
353
- writeFileAtomic(abs, JSON.stringify(data, null, 2) + '\n');
467
+ writeFileAtomic(abs, JSON.stringify(data, null, 2) + '\n', { root: projectRoot });
354
468
  removed.push(abs);
355
469
  }
356
470
  } catch (e) {
@@ -18,7 +18,6 @@ import { homedir } from 'node:os';
18
18
  import { join } from 'node:path';
19
19
  import { wireServer, recordWiring, removeWiring } from './harness-wiring.mjs';
20
20
  import { which } from './codegraph-manager.mjs';
21
- './errors.mjs';
22
21
 
23
22
  export const MCP_PACKAGE = 'archgen-mcp';
24
23
 
@@ -114,8 +113,8 @@ export function wireArchgenMcp(opts) {
114
113
  * @param {string} projectRoot
115
114
  * @param {string} [home]
116
115
  */
117
- export function uninstallArchgenMcp(projectRoot, home = homedir()) {
118
- return removeWiring(projectRoot, 'archgen', home);
116
+ export function uninstallArchgenMcp(projectRoot, home = homedir(), location = 'both') {
117
+ return removeWiring(projectRoot, 'archgen', home, location);
119
118
  }
120
119
 
121
120
  /**
@@ -5,16 +5,39 @@
5
5
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
 
8
- /** @returns {string[]} error findings (empty = coverage OK) */
8
+ /**
9
+ * @param {string} planDir
10
+ * @param {any[]} tasks
11
+ * @returns {{findings: string[], referenced: Set<string>, mentionedInTasks: Set<string>}}
12
+ * `findings` error findings (empty = coverage OK)
13
+ * `referenced` task ids that a plan file mentions
14
+ * `mentionedInTasks` task ids another task's text mentions
15
+ *
16
+ * The sets are RETURNED, not just used internally. A consumer that needs to
17
+ * know which tasks are covered must read them from here: re-deriving the
18
+ * answer from `findings` — which are NEGATIVE — inverts it, because an
19
+ * uncovered task's finding names the task.
20
+ */
9
21
  export function checkPlanCoverage(planDir, tasks) {
10
22
  const findings = [];
11
- if (!existsSync(planDir)) return [`plans dir not found: ${planDir}`];
12
- const files = readdirSync(planDir).filter((f) => f.endsWith('.md'));
13
- const planText = files.map((f) => readFileSync(join(planDir, f), 'utf8')).join('\n');
23
+ const referenced = new Set();
24
+ const mentionedInTasks = new Set();
25
+ if (!existsSync(planDir)) return { findings: [`plans dir not found: ${planDir}`], referenced, mentionedInTasks };
26
+ let files;
27
+ try {
28
+ files = readdirSync(planDir).filter((f) => f.endsWith('.md'));
29
+ } catch (e) {
30
+ return { findings: [`cannot read plans dir ${planDir}: ${e.message}`], referenced, mentionedInTasks };
31
+ }
32
+ let planText = '';
33
+ try {
34
+ planText = files.map((f) => readFileSync(join(planDir, f), 'utf8')).join('\n');
35
+ } catch (e) {
36
+ return { findings: [`cannot read a plan file under ${planDir}: ${e.message}`], referenced, mentionedInTasks };
37
+ }
14
38
 
15
39
  const taskIds = new Set(tasks.map((t) => t.id));
16
40
  // Task ids referenced in plans: bare tokens that match an existing-or-not id pattern.
17
- const referenced = new Set();
18
41
  for (const t of taskIds) {
19
42
  const re = new RegExp(`\\b${escapeRe(t)}\\b`);
20
43
  if (re.test(planText)) referenced.add(t);
@@ -22,11 +45,25 @@ export function checkPlanCoverage(planDir, tasks) {
22
45
  for (const t of tasks) {
23
46
  if (!referenced.has(t.id)) findings.push(`task '${t.id}' is not mentioned in any plan file under ${planDir}`);
24
47
  }
48
+ // A task may also be "covered" by ANOTHER task naming it — the dependency
49
+ // graph documents it.
50
+ for (const t of tasks) {
51
+ for (const other of tasks) {
52
+ if (other?.id === t.id) continue;
53
+ if (mentions(other, t.id)) mentionedInTasks.add(t.id);
54
+ }
55
+ }
25
56
  // Inverse: plan mentions ids like TASK-xx not present in tasks.yaml.
26
57
  for (const m of planText.matchAll(/\b[A-Z][A-Z0-9]+-[0-9]+\b/g)) {
27
58
  if (!taskIds.has(m[0])) findings.push(`plan references unknown task id '${m[0]}' (not in tasks.yaml)`);
28
59
  }
29
- return findings;
60
+ return { findings, referenced, mentionedInTasks };
61
+ }
62
+
63
+ /** Does any task reference `id` in its artifacts / acceptance text? */
64
+ function mentions(task, id) {
65
+ const hay = JSON.stringify([task?.artifacts ?? [], task?.acceptance ?? [], task?.title ?? '']);
66
+ return hay.includes(id);
30
67
  }
31
68
 
32
69
  function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
@@ -85,10 +85,10 @@ function bfsDistances(start, adjacency) {
85
85
  * @param {string} input
86
86
  * @returns {{root: string, slug: string, tasksFile: string}}
87
87
  */
88
- export function resolvePlanScope(input) {
88
+ export function resolvePlanScope(input, projectRoot = null) {
89
89
  let scoped;
90
90
  try {
91
- scoped = resolveSlugInput(input);
91
+ scoped = resolveSlugInput(input, projectRoot);
92
92
  } catch (e) {
93
93
  if (e instanceof ScopeError) throw invalidInput(`plan-graph: ${e.message}`, { input });
94
94
  throw invalidInput(`cannot read ${input} (missing or unreadable)`, { input });
@@ -101,7 +101,7 @@ export function resolvePlanScope(input) {
101
101
  * @param {string} input slug dir or tasks.yaml path
102
102
  */
103
103
  function loadPlan(input) {
104
- const { root, slug, tasksFile } = resolvePlanScope(input);
104
+ const { root, slug, tasksFile } = resolvePlanScope(input, opts.projectRoot ?? null);
105
105
 
106
106
  let raw;
107
107
  try {
@@ -67,12 +67,19 @@ export function detectHarness(registry, opts) {
67
67
  return { detected: found, primary: found[0], capabilities: platformCapabilities(registry, found[0].id) };
68
68
  }
69
69
 
70
- /** The rank of a harness in the detection order (unknown → last). */
70
+ /**
71
+ * The rank of a harness in the detection order (unknown → last).
72
+ *
73
+ * The match is EXACT. A prefix match ranked `code` against `codex` — two
74
+ * different harnesses — and whichever came first in the registry won, so
75
+ * the detected platform depended on the registry's ordering rather than on
76
+ * what is actually installed.
77
+ */
71
78
  function rank(id, order) {
72
79
  const keys = Object.keys(order);
73
80
  const i = keys.findIndex((k) => {
74
81
  const clean = k.replace(/^\./, '').replace(/\/$/, '').replace(/\.json$/, '');
75
- return clean === id || clean.startsWith(id);
82
+ return clean === id;
76
83
  });
77
84
  return i === -1 ? keys.length : i;
78
85
  }
@@ -21,6 +21,19 @@ import { writeFileAtomic } from './fs-safety.mjs';
21
21
  * @param {any[]} waves resolved waves
22
22
  * @returns {string}
23
23
  */
24
+ /**
25
+ * The wave execution resumes at, 1-based.
26
+ *
27
+ * When every wave is done there is no NEXT wave, so the resume point is the
28
+ * LAST wave — not one past the end of the list. The file and the tool
29
+ * response used two different formulas, so the snapshot could say "wave 3 of
30
+ * 2" while the response said wave 2.
31
+ */
32
+ export function resumeWaveIndex(waves, statusOf) {
33
+ const active = waves.findIndex((w) => w.some((t) => statusOf(t) !== 'done'));
34
+ return active === -1 ? waves.length : active + 1;
35
+ }
36
+
24
37
  export function renderResume(slug, tasks, waves) {
25
38
  const byId = new Map(tasks.map((t) => [t?.id, t]));
26
39
  const statusOf = (t) => String(t?.status ?? 'pending');
@@ -32,14 +45,13 @@ export function renderResume(slug, tasks, waves) {
32
45
 
33
46
  // The first wave that still has work in it is where execution resumes.
34
47
  const activeWaveIndex = waves.findIndex((w) => w.some((t) => statusOf(t) !== 'done'));
35
- const waveIndex = activeWaveIndex === -1 ? waves.length : activeWaveIndex;
36
48
  const remaining = waves[activeWaveIndex] ?? [];
37
49
 
38
50
  const lines = [];
39
51
  lines.push(`# Resume — ${slug}`);
40
52
  lines.push('');
41
53
  lines.push(`- slug: ${slug}`);
42
- lines.push(`- wave: ${waveIndex + 1} of ${waves.length}`);
54
+ lines.push(`- wave: ${resumeWaveIndex(waves, statusOf)} of ${waves.length}`);
43
55
  lines.push(`- done: ${done.length ? done.join(', ') : 'none'}`);
44
56
  lines.push(`- failed: ${failed.length ? failed.join(', ') : 'none'}`);
45
57
  lines.push(`- blocked: ${blocked.length ? blocked.join(', ') : 'none'}`);
@@ -91,13 +103,13 @@ export function writeResumeSnapshot(slugDir, waves) {
91
103
  const body = renderResume(slug, tasks, waves);
92
104
  const out = join(slugDir, '.agent', 'resume.md');
93
105
  mkdirSync(dirname(out), { recursive: true });
94
- writeFileAtomic(out, body);
106
+ writeFileAtomic(out, body, { root: slugDir });
95
107
 
96
108
  const statusOf = (t) => String(t?.status ?? 'pending');
97
109
  const activeWaveIndex = waves.findIndex((w) => w.some((t) => statusOf(t) !== 'done'));
98
110
  return {
99
111
  path: out,
100
- waveIndex: activeWaveIndex === -1 ? waves.length : activeWaveIndex + 1,
112
+ waveIndex: resumeWaveIndex(waves, statusOf),
101
113
  done: tasks.filter((t) => statusOf(t) === 'done').map((t) => t.id),
102
114
  failed: tasks.filter((t) => statusOf(t) === 'failed').map((t) => t.id),
103
115
  blocked: tasks.filter((t) => statusOf(t) === 'blocked').map((t) => t.id),
@@ -19,7 +19,7 @@
19
19
  // - Everything else throws ScopeError. Missing/unreadable inputs surface as
20
20
  // the underlying realpath/stat error so callers keep their own messages.
21
21
  import { realpathSync, statSync } from 'node:fs';
22
- import { basename, dirname, sep } from 'node:path';
22
+ import { basename, dirname, resolve, sep } from 'node:path';
23
23
 
24
24
  export class ScopeError extends Error {
25
25
  constructor(input) {
@@ -34,7 +34,19 @@ function segmentsOf(abs) {
34
34
  return process.platform === 'win32' ? abs.split(/[\\/]+/).filter(Boolean) : abs.split(sep).filter(Boolean);
35
35
  }
36
36
 
37
- export function resolveSlugInput(rawInput) {
37
+ /**
38
+ * Resolve a path to the artifact space it names.
39
+ *
40
+ * A path qualifies when it is a `.archgen/<slug>/` directory (or the
41
+ * `tasks.yaml` inside one). When `root` is given, the artifact space must
42
+ * also be INSIDE that root — otherwise the gate would accept
43
+ * `/somewhere/else/.archgen/demo`, which is not this project's artifacts.
44
+ *
45
+ * @param {string} rawInput
46
+ * @param {string|null} [root] the project root the artifact space must be inside
47
+ * @returns {{kind: 'dir'|'tasks', root: string, slug: string}}
48
+ */
49
+ export function resolveSlugInput(rawInput, root = null) {
38
50
  let real;
39
51
  try {
40
52
  real = realpathSync(rawInput); // follows symlinks: judge the TARGET, not the link
@@ -53,13 +65,35 @@ export function resolveSlugInput(rawInput) {
53
65
  const depthFromArchgen = parts.length - 1 - i; // segments after this `.archgen`
54
66
  if (st.isDirectory() && depthFromArchgen === 1) {
55
67
  // (a) `<...>/.archgen/<slug>` — the slug dir itself
56
- return { kind: 'dir', root: real, slug: parts[i + 1] };
68
+ const slugRoot = real;
69
+ if (root && !isWithin(slugRoot, root)) throw new ScopeError(rawInput);
70
+ return { kind: 'dir', root: slugRoot, slug: parts[i + 1] };
57
71
  }
58
72
  if (st.isFile() && basename(real) === 'tasks.yaml' && depthFromArchgen === 2) {
59
73
  // (b) `<...>/.archgen/<slug>/tasks.yaml` — direct tasks.yaml mode
60
- const root = dirname(real);
61
- return { kind: 'tasks', root, slug: basename(root) };
74
+ const slugRoot = dirname(real);
75
+ if (root && !isWithin(slugRoot, root)) throw new ScopeError(rawInput);
76
+ return { kind: 'tasks', root: slugRoot, slug: basename(slugRoot) };
62
77
  }
63
78
  }
64
79
  throw new ScopeError(rawInput);
65
80
  }
81
+
82
+ /**
83
+ * True when `abs` is inside `root`.
84
+ *
85
+ * The REAL comparison is authoritative when both paths exist: it is what
86
+ * catches a symlinked parent pointing out of the tree. The lexical
87
+ * comparison only stands in when the target does not exist yet.
88
+ */
89
+ function isWithin(abs, root) {
90
+ const resolvedRoot = resolve(root);
91
+ const resolved = resolve(abs);
92
+ try {
93
+ const realRoot = realpathSync(resolvedRoot);
94
+ const real = realpathSync(resolved);
95
+ return real === realRoot || real.startsWith(realRoot + sep);
96
+ } catch {
97
+ return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + sep);
98
+ }
99
+ }
@@ -17,7 +17,7 @@
17
17
  import { readFileSync } from 'node:fs';
18
18
  import { parseYaml, stringifyYaml } from './yaml.mjs';
19
19
  import { conflict, invalidInput } from './errors.mjs';
20
- import { writeFileAtomic } from './fs-safety.mjs';
20
+ import { writeFileAtomic, withFileLock } from './fs-safety.mjs';
21
21
 
22
22
  export const STATUSES = ['pending', 'ready', 'running', 'blocked', 'done', 'failed'];
23
23
 
@@ -32,6 +32,23 @@ export function detectEol(raw) {
32
32
  return crlfCount > loneLfCount ? '\r\n' : '\n';
33
33
  }
34
34
 
35
+ /**
36
+ * Do two task ids name the same task?
37
+ *
38
+ * Textually equal ids match. A NUMERIC id also matches by value, because
39
+ * YAML resolves `id: 007` to the number 7 — the agent holds the text it
40
+ * read and the parser holds the value. A QUOTED id is text and matches
41
+ * exactly: `id: "7"` and "07" are different tasks.
42
+ */
43
+ export function sameId(a, b) {
44
+ const sa = String(a ?? '');
45
+ const sb = String(b ?? '');
46
+ if (sa === sb) return true;
47
+ if (typeof a !== 'number') return false;
48
+ const nb = Number(sb);
49
+ return sb.trim() !== '' && Number.isFinite(nb) && a === nb;
50
+ }
51
+
35
52
  /**
36
53
  * Set a task's status atomically.
37
54
  *
@@ -48,6 +65,15 @@ export function setTaskStatus(file, taskId, nextStatus, opts = {}) {
48
65
  throw invalidInput(`invalid status '${nextStatus}' (allowed: ${STATUSES.join('|')})`, { status: nextStatus });
49
66
  }
50
67
 
68
+ // The whole read-modify-write runs under an exclusive lock. Atomic writes
69
+ // keep a reader from seeing a half-written file, but two writers still
70
+ // lose each other's changes without this: parallel workers in one wave
71
+ // each read the same bytes and each write its own status over the other's.
72
+ return withFileLock(file, () => mutateStatus(file, taskId, nextStatus, force));
73
+ }
74
+
75
+ /** The read-modify-write itself. Runs with the file's lock held. */
76
+ function mutateStatus(file, taskId, nextStatus, force) {
51
77
  let raw;
52
78
  try {
53
79
  raw = readFileSync(file, 'utf8');
@@ -71,7 +97,11 @@ export function setTaskStatus(file, taskId, nextStatus, opts = {}) {
71
97
  const tasks = data?.tasks;
72
98
  if (!Array.isArray(tasks)) throw invalidInput(`${file}: missing 'tasks:' sequence`, { file });
73
99
 
74
- const task = tasks.find((t) => t && t.id === taskId);
100
+ // Ids are matched as TEXT, and — when both sides are numeric — as NUMBERS.
101
+ // YAML reads `id: 007` as the number 7, so the id the agent holds ("007")
102
+ // and the id the file holds (7) are different strings for the same task.
103
+ // A strict comparison made such a task permanently unaddressable.
104
+ const task = tasks.find((t) => t && sameId(t.id, taskId));
75
105
  if (!task) throw invalidInput(`task '${taskId}' not found in ${file}`, { file, taskId });
76
106
 
77
107
  const prev = task.status ?? 'pending';
@@ -82,9 +112,10 @@ export function setTaskStatus(file, taskId, nextStatus, opts = {}) {
82
112
  );
83
113
  }
84
114
 
115
+ const changed = prev !== nextStatus;
85
116
  task.status = nextStatus;
86
117
  const content = stringifyYaml(data, comments, { eol });
87
118
  writeFileAtomic(file, content);
88
119
 
89
- return { taskId, from: prev, to: nextStatus, changed: true, file };
120
+ return { taskId, from: prev, to: nextStatus, changed, file };
90
121
  }