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.
- package/README.md +14 -5
- package/assets/harness-mcp.json +285 -55
- package/assets/references/artifact-templates.md +20 -6
- package/assets/references/platforms.md +7 -5
- package/bin/archgen-mcp.mjs +6 -4
- package/package.json +1 -1
- package/src/context.mjs +43 -3
- package/src/core/briefs.mjs +13 -0
- package/src/core/codegraph-manager.mjs +112 -48
- package/src/core/compat.mjs +13 -3
- package/src/core/doc-navigator.mjs +26 -7
- package/src/core/fs-safety.mjs +98 -10
- package/src/core/graph.mjs +8 -3
- package/src/core/harness-wiring.mjs +132 -18
- package/src/core/mcp-server-manager.mjs +2 -3
- package/src/core/plan-coverage.mjs +43 -6
- package/src/core/plan-grapher.mjs +3 -3
- package/src/core/platform-caps.mjs +9 -2
- package/src/core/resume-snapshot.mjs +16 -4
- package/src/core/scope.mjs +39 -5
- package/src/core/status-mutator.mjs +34 -3
- package/src/core/ui-channel.mjs +55 -4
- package/src/core/validator.mjs +16 -3
- package/src/core/verifier.mjs +3 -9
- package/src/core/worker-contract.mjs +16 -2
- package/src/core/yaml.mjs +31 -6
- package/src/tools/_register.mjs +5 -4
- package/src/tools/codegraph.mjs +0 -2
- package/src/tools/docs.mjs +6 -6
- package/src/tools/help.mjs +1 -1
- package/src/tools/plan.mjs +44 -7
- package/src/tools/setup.mjs +7 -3
- package/src/tools/tasks.mjs +38 -5
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
//
|
|
16
16
|
// SAFETY: MCP configs are written only with approval (otherwise the plan is
|
|
17
17
|
// reported); non-JSON configs are reported, never rewritten.
|
|
18
|
-
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
19
19
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { join } from 'node:path';
|
|
@@ -26,20 +26,81 @@ export const CODEGRAPH_PACKAGE = '@colbymchenry/codegraph';
|
|
|
26
26
|
|
|
27
27
|
/** Run a command; never throws. Returns {ok, status, stdout, stderr}. */
|
|
28
28
|
export function run(cmd, args, opts = {}) {
|
|
29
|
-
const { timeout = 120000, cwd, env } = opts;
|
|
29
|
+
const { timeout = 120000, cwd, env, shell } = opts;
|
|
30
30
|
const r = spawnSync(cmd, args, {
|
|
31
31
|
encoding: 'utf8',
|
|
32
32
|
timeout,
|
|
33
33
|
cwd,
|
|
34
34
|
env: env ? { ...process.env, ...env } : process.env,
|
|
35
|
-
shell
|
|
36
|
-
//
|
|
37
|
-
|
|
35
|
+
// The caller's `shell` wins; otherwise only a Windows .cmd/.bat shim needs
|
|
36
|
+
// one. The option used to be dropped, so a caller asking for a shell did
|
|
37
|
+
// not get one.
|
|
38
|
+
shell: shell === true || (process.platform === 'win32' && /\.(cmd|bat)$/i.test(cmd)),
|
|
38
39
|
});
|
|
39
40
|
if (r.error) return { ok: false, status: -1, stdout: '', stderr: String(r.error.message ?? r.error) };
|
|
40
41
|
return { ok: r.status === 0, status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Run a command asynchronously; never throws. Returns {ok, status, stdout, stderr}.
|
|
46
|
+
*
|
|
47
|
+
* The SYNCHRONOUS form blocks the event loop for the child's whole lifetime,
|
|
48
|
+
* and the stdio MCP server cannot answer a single request while that
|
|
49
|
+
* happens — a global install or a full index can take minutes, so every
|
|
50
|
+
* other request simply queues behind it.
|
|
51
|
+
*
|
|
52
|
+
* 'close' (not 'exit') decides the outcome: 'exit' fires when the process is
|
|
53
|
+
* gone, while its output may still be in flight, and the last chunk of a
|
|
54
|
+
* long install log would be lost.
|
|
55
|
+
*/
|
|
56
|
+
export function runAsync(cmd, args, opts = {}) {
|
|
57
|
+
const { timeout = 120000, cwd, env, shell } = opts;
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
let child;
|
|
60
|
+
try {
|
|
61
|
+
child = spawn(cmd, args, {
|
|
62
|
+
cwd,
|
|
63
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
64
|
+
shell: shell === true,
|
|
65
|
+
});
|
|
66
|
+
} catch (e) {
|
|
67
|
+
resolve({ ok: false, status: -1, stdout: '', stderr: String(e?.message ?? e) });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let stdout = '';
|
|
71
|
+
let stderr = '';
|
|
72
|
+
let settled = false;
|
|
73
|
+
const finish = (result) => {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
settled = true;
|
|
76
|
+
if (timer) clearTimeout(timer);
|
|
77
|
+
resolve(result);
|
|
78
|
+
};
|
|
79
|
+
const timer =
|
|
80
|
+
timeout > 0
|
|
81
|
+
? setTimeout(() => {
|
|
82
|
+
try {
|
|
83
|
+
child.kill('SIGKILL');
|
|
84
|
+
} catch {
|
|
85
|
+
/* already gone */
|
|
86
|
+
}
|
|
87
|
+
}, timeout)
|
|
88
|
+
: null;
|
|
89
|
+
child.stdout?.on('data', (d) => {
|
|
90
|
+
stdout += d.toString();
|
|
91
|
+
});
|
|
92
|
+
child.stderr?.on('data', (d) => {
|
|
93
|
+
stderr += d.toString();
|
|
94
|
+
});
|
|
95
|
+
child.on('error', (e) => {
|
|
96
|
+
finish({ ok: false, status: -1, stdout, stderr: String(e?.message ?? e) });
|
|
97
|
+
});
|
|
98
|
+
child.on('close', (code) => {
|
|
99
|
+
finish({ ok: code === 0, status: code ?? -1, stdout, stderr });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
43
104
|
/** Locate an executable on PATH (cross-platform, no shell). */
|
|
44
105
|
export function which(bin, env = process.env) {
|
|
45
106
|
const exts = process.platform === 'win32' ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';') : [''];
|
|
@@ -102,7 +163,6 @@ export function telemetryState(bin) {
|
|
|
102
163
|
* approved?: boolean,
|
|
103
164
|
* dryRun?: boolean,
|
|
104
165
|
* telemetry?: 'keep'|'off'|'on',
|
|
105
|
-
* noUpgrade?: boolean,
|
|
106
166
|
* noInstall?: boolean,
|
|
107
167
|
* home?: string,
|
|
108
168
|
* env?: Record<string, string|undefined>,
|
|
@@ -119,7 +179,6 @@ export async function manageCodegraph(opts) {
|
|
|
119
179
|
approved = false,
|
|
120
180
|
dryRun = false,
|
|
121
181
|
telemetry = 'keep',
|
|
122
|
-
noUpgrade = false,
|
|
123
182
|
noInstall = false,
|
|
124
183
|
home = homedir(),
|
|
125
184
|
env = process.env,
|
|
@@ -140,11 +199,26 @@ export async function manageCodegraph(opts) {
|
|
|
140
199
|
ok: true,
|
|
141
200
|
};
|
|
142
201
|
|
|
143
|
-
/**
|
|
144
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Resolve the CodeGraph CLI.
|
|
204
|
+
*
|
|
205
|
+
* THE RULE: install it when it is MISSING, and leave it alone when it is
|
|
206
|
+
* ALREADY THERE.
|
|
207
|
+
*
|
|
208
|
+
* An installed CodeGraph is the user's to manage. Upgrading it behind
|
|
209
|
+
* their back changes a program they may have pinned, and the upgrade ran
|
|
210
|
+
* on every bootstrap — so a plain `npx archgen-skill init` could rewrite a
|
|
211
|
+
* working installation. The version that is already on the machine is the
|
|
212
|
+
* version the MCP config points at, and it works.
|
|
213
|
+
*
|
|
214
|
+
* `install` lets a caller ask for the RESOLUTION only — the binary's path
|
|
215
|
+
* without touching the machine. That is what `wire` needs: wiring is a
|
|
216
|
+
* configuration change; installing a program globally is not.
|
|
217
|
+
*/
|
|
218
|
+
const ensureCli = async ({ install = true } = {}) => {
|
|
145
219
|
let found = findCodegraph(env);
|
|
146
220
|
if (!found) {
|
|
147
|
-
if (noInstall) {
|
|
221
|
+
if (noInstall || !install) {
|
|
148
222
|
// Caller manages the CLI — fall back to the npx form in the MCP config.
|
|
149
223
|
report.cli.action = 'absent';
|
|
150
224
|
report.cli.detail = `not installed and installation was skipped — the MCP config will use "npx -y ${CODEGRAPH_PACKAGE} serve --mcp"`;
|
|
@@ -156,7 +230,7 @@ export async function manageCodegraph(opts) {
|
|
|
156
230
|
}
|
|
157
231
|
// Official install path; always the latest published release.
|
|
158
232
|
const inv = npmInvocation(['install', '-g', CODEGRAPH_PACKAGE], env);
|
|
159
|
-
const r =
|
|
233
|
+
const r = await runAsync(inv.command, inv.args, { timeout: 600000, shell: inv.shell, env });
|
|
160
234
|
found = findCodegraph(env);
|
|
161
235
|
if (!found) {
|
|
162
236
|
report.cli.action = 'failed';
|
|
@@ -166,37 +240,17 @@ export async function manageCodegraph(opts) {
|
|
|
166
240
|
}
|
|
167
241
|
report.cli.action = 'installed';
|
|
168
242
|
} else {
|
|
243
|
+
// Already installed: report it and stop. No upgrade, no version check.
|
|
169
244
|
report.cli.action = 'present';
|
|
170
245
|
}
|
|
171
246
|
report.cli.present = true;
|
|
172
247
|
report.cli.bin = found.bin;
|
|
173
248
|
report.cli.version = found.version;
|
|
174
|
-
|
|
175
|
-
if (noUpgrade) return found;
|
|
176
|
-
|
|
177
|
-
// Freshness via the official updater (no-op when already current).
|
|
178
|
-
const chk = run(found.bin, ['upgrade', '--check'], { timeout: 120000, env });
|
|
179
|
-
const m = /latest\s+v?([0-9][^\s]*)/i.exec((chk.stdout ?? '') + (chk.stderr ?? ''));
|
|
180
|
-
report.cli.latest = m ? m[1] : null; // informational only
|
|
181
|
-
if (dryRun) {
|
|
182
|
-
report.cli.action += '+upgrade';
|
|
183
|
-
return found;
|
|
184
|
-
}
|
|
185
|
-
const up = run(found.bin, ['upgrade'], { timeout: 600000, env });
|
|
186
|
-
const after = findCodegraph(env);
|
|
187
|
-
if (after) {
|
|
188
|
-
report.cli.version = after.version;
|
|
189
|
-
report.cli.bin = after.bin;
|
|
190
|
-
report.cli.action += /already up to date/i.test(up.stdout ?? '') ? '+current' : '+upgraded';
|
|
191
|
-
} else {
|
|
192
|
-
report.cli.action += '+upgrade-failed';
|
|
193
|
-
report.ok = false;
|
|
194
|
-
}
|
|
195
249
|
return found;
|
|
196
250
|
};
|
|
197
251
|
|
|
198
252
|
/** Wire the MCP server into every detected harness (native + fallback). */
|
|
199
|
-
const wireHarnesses = (cli) => {
|
|
253
|
+
const wireHarnesses = async (cli) => {
|
|
200
254
|
const detected = detectHarnesses(registry, projectRoot, home);
|
|
201
255
|
const wanted = targets ?? detected.map((d) => d.id);
|
|
202
256
|
const selected = detected.filter((d) => wanted.includes(d.id));
|
|
@@ -222,7 +276,7 @@ export async function manageCodegraph(opts) {
|
|
|
222
276
|
report.wiring.native.push({ targets: uniqueNative.join(','), location: loc, status: 'planned' });
|
|
223
277
|
continue;
|
|
224
278
|
}
|
|
225
|
-
const r =
|
|
279
|
+
const r = await runAsync(
|
|
226
280
|
cli.bin ?? 'codegraph',
|
|
227
281
|
['install', `--target=${uniqueNative.join(',')}`, `--location=${loc}`, '--yes'],
|
|
228
282
|
{
|
|
@@ -258,6 +312,7 @@ export async function manageCodegraph(opts) {
|
|
|
258
312
|
binPath: cli.bin,
|
|
259
313
|
fallbackCommand: 'codegraph',
|
|
260
314
|
skipHarnesses: selected.filter((d) => d.spec.codegraphTarget).map((d) => d.id),
|
|
315
|
+
targets: selected.map((d) => d.id),
|
|
261
316
|
});
|
|
262
317
|
for (const w of manual.wired) {
|
|
263
318
|
report.wiring.manual.push({ harness: w.harness, path: w.path, key: w.key, ...w });
|
|
@@ -290,7 +345,7 @@ export async function manageCodegraph(opts) {
|
|
|
290
345
|
};
|
|
291
346
|
|
|
292
347
|
/** Index the project: `codegraph init` builds `.codegraph/` + the first graph. */
|
|
293
|
-
const initIndex = (cli) => {
|
|
348
|
+
const initIndex = async (cli) => {
|
|
294
349
|
const codegraphDir = join(projectRoot, '.codegraph');
|
|
295
350
|
if (existsSync(codegraphDir)) {
|
|
296
351
|
report.index.action = 'already-indexed';
|
|
@@ -311,7 +366,7 @@ export async function manageCodegraph(opts) {
|
|
|
311
366
|
return;
|
|
312
367
|
}
|
|
313
368
|
// Official indexing command: creates .codegraph/ AND builds the graph.
|
|
314
|
-
const r =
|
|
369
|
+
const r = await runAsync(cli.bin ?? 'codegraph', ['init', projectRoot], { timeout: 900000, env });
|
|
315
370
|
if (!r.ok) {
|
|
316
371
|
report.index.action = 'failed';
|
|
317
372
|
report.index.detail = (r.stderr || r.stdout || 'codegraph init failed').trim().split('\n')[0];
|
|
@@ -332,7 +387,7 @@ export async function manageCodegraph(opts) {
|
|
|
332
387
|
}
|
|
333
388
|
};
|
|
334
389
|
|
|
335
|
-
const applyTelemetry = (cli) => {
|
|
390
|
+
const applyTelemetry = async (cli) => {
|
|
336
391
|
const state = telemetryState(cli.bin);
|
|
337
392
|
report.telemetry.state = state;
|
|
338
393
|
if (telemetry === 'keep' || !cli.bin) return;
|
|
@@ -356,7 +411,7 @@ export async function manageCodegraph(opts) {
|
|
|
356
411
|
report.cli.version = cli?.version ?? null;
|
|
357
412
|
// Official freshness check — reports what "latest" means today, no pin.
|
|
358
413
|
if (cli) {
|
|
359
|
-
const chk =
|
|
414
|
+
const chk = await runAsync(cli.bin, ['upgrade', '--check'], { timeout: 120000, env });
|
|
360
415
|
const m = /latest\s+v?([0-9][^\s]*)/i.exec((chk.stdout ?? '') + (chk.stderr ?? ''));
|
|
361
416
|
report.cli.latest = m ? m[1] : null;
|
|
362
417
|
}
|
|
@@ -382,23 +437,32 @@ export async function manageCodegraph(opts) {
|
|
|
382
437
|
}
|
|
383
438
|
report.index.action = existsSync(join(projectRoot, '.codegraph')) ? 'indexed' : 'not-indexed';
|
|
384
439
|
} else if (mode === 'init') {
|
|
385
|
-
const cli = ensureCli();
|
|
386
|
-
applyTelemetry(cli);
|
|
387
|
-
initIndex(cli);
|
|
440
|
+
const cli = await ensureCli();
|
|
441
|
+
await applyTelemetry(cli);
|
|
442
|
+
await initIndex(cli);
|
|
443
|
+
} else if (mode === 'wire') {
|
|
444
|
+
// WIRE ONLY. It resolves the binary (so the config can point at it) but
|
|
445
|
+
// never installs, never upgrades and never writes a global setting: a
|
|
446
|
+
// wiring request is a request to change the MCP CONFIGS.
|
|
447
|
+
const cli = await ensureCli({ install: false });
|
|
448
|
+
const detected = await wireHarnesses(cli);
|
|
449
|
+
report.detected = detected.map((d) => d.id);
|
|
388
450
|
} else {
|
|
389
|
-
// ensure
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
451
|
+
// ensure — install the CLI if it is MISSING (an installed one is left
|
|
452
|
+
// exactly as it is), wire the configs, and index the project.
|
|
453
|
+
const cli = await ensureCli();
|
|
454
|
+
await applyTelemetry(cli);
|
|
455
|
+
const detected = await wireHarnesses(cli);
|
|
393
456
|
if (mode === 'ensure') {
|
|
394
457
|
if (!approved && !dryRun) {
|
|
395
|
-
// Without approval: print the plan, write no MCP config. The CLI
|
|
396
|
-
// is still installed
|
|
458
|
+
// Without approval: print the plan, write no MCP config. The CLI
|
|
459
|
+
// itself is still installed if it was missing (a program, not a user
|
|
460
|
+
// config); an existing one is never touched.
|
|
397
461
|
report.ok = true;
|
|
398
462
|
report.planned = true;
|
|
399
463
|
report.index.action = 'would-index';
|
|
400
464
|
} else {
|
|
401
|
-
initIndex(cli);
|
|
465
|
+
await initIndex(cli);
|
|
402
466
|
}
|
|
403
467
|
}
|
|
404
468
|
report.detected = detected.map((d) => d.id);
|
package/src/core/compat.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// skill/SKILL.md frontmatter
|
|
11
11
|
// metadata:
|
|
12
12
|
// requires:
|
|
13
|
-
// archgen_mcp: ">=
|
|
13
|
+
// archgen_mcp: ">=x.y.z"
|
|
14
14
|
//
|
|
15
15
|
// `doctor` reports the mismatch; `bootstrap` warns before wiring; CI refuses
|
|
16
16
|
// to ship a skill whose declared minimum exceeds the server's version.
|
|
@@ -29,11 +29,21 @@ export function parseVersion(v) {
|
|
|
29
29
|
return m ? { major: +m[1], minor: +m[2], patch: +m[3] } : null;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Compare two versions: -1 / 0 / 1.
|
|
34
|
+
*
|
|
35
|
+
* An unparseable version THROWS. The question "which of these is newer?"
|
|
36
|
+
* has no answer when one of them is not a version, and answering "0" — the
|
|
37
|
+
* old behaviour — said "they are the same", which is false and quietly
|
|
38
|
+
* wrong. `satisfies` answers a different question ("does this version meet
|
|
39
|
+
* this requirement?") and handles an unknown version as "meets nothing".
|
|
40
|
+
*/
|
|
33
41
|
export function compareVersions(a, b) {
|
|
34
42
|
const x = parseVersion(a);
|
|
35
43
|
const y = parseVersion(b);
|
|
36
|
-
if (!x || !y)
|
|
44
|
+
if (!x || !y) {
|
|
45
|
+
throw invalidInput(`cannot compare versions: '${a}' and '${b}' are not both versions`, { a, b });
|
|
46
|
+
}
|
|
37
47
|
for (const k of ['major', 'minor', 'patch']) {
|
|
38
48
|
if (x[k] !== y[k]) return x[k] < y[k] ? -1 : 1;
|
|
39
49
|
}
|
|
@@ -65,10 +65,10 @@ const FR_ID_SOURCE = '(?<![A-Za-z0-9_])FR-[A-Z0-9]+-\\d+(?![A-Za-z0-9_])';
|
|
|
65
65
|
* @param {string} input
|
|
66
66
|
* @returns {{slugDir: string, repoRoot: string, slug: string}}
|
|
67
67
|
*/
|
|
68
|
-
export function resolveDocScope(input) {
|
|
68
|
+
export function resolveDocScope(input, projectRoot = null) {
|
|
69
69
|
let scoped;
|
|
70
70
|
try {
|
|
71
|
-
scoped = resolveSlugInput(input);
|
|
71
|
+
scoped = resolveSlugInput(input, projectRoot);
|
|
72
72
|
} catch (e) {
|
|
73
73
|
if (e instanceof ScopeError) throw invalidInput(`doc-index: ${e.message}`, { input });
|
|
74
74
|
throw invalidInput(`cannot read ${input} (missing or not a directory)`, { input });
|
|
@@ -121,13 +121,14 @@ function walkFiles(slugDir, filter) {
|
|
|
121
121
|
const rel = stack.pop();
|
|
122
122
|
for (const e of readdirSync(rel ? join(slugDir, rel) : slugDir, { withFileTypes: true })) {
|
|
123
123
|
if (e.name.startsWith('.')) continue;
|
|
124
|
-
const r = rel ? `${rel}/${e.name}` : e.name
|
|
124
|
+
const r = rel ? `${rel}/${e.name}` : rel === '' ? e.name : `${rel}/${e.name}`;
|
|
125
125
|
if (e.isDirectory()) stack.push(r);
|
|
126
126
|
else if (filter.test(e.name)) candidates.push(r);
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
const chosen = new Map(); // real key -> { rel, isLink }
|
|
130
130
|
let duplicatesSkipped = 0;
|
|
131
|
+
let escapesSkipped = 0;
|
|
131
132
|
for (const r of candidates.sort()) {
|
|
132
133
|
const abs = join(slugDir, r);
|
|
133
134
|
let key;
|
|
@@ -139,6 +140,14 @@ function walkFiles(slugDir, filter) {
|
|
|
139
140
|
key = resolve(abs); // dangling symlink: keep, keyed by spelled path
|
|
140
141
|
isLink = false;
|
|
141
142
|
}
|
|
143
|
+
// A file whose REAL path is outside the artifact space is a symlink out
|
|
144
|
+
// of it. Reading it would return whatever it points at — a private key,
|
|
145
|
+
// a file from elsewhere on the machine — inside a tool that promises to
|
|
146
|
+
// read nothing but archgen's own artifacts. Such a file is skipped.
|
|
147
|
+
if (key !== slugDir && !key.startsWith(slugDir + sep)) {
|
|
148
|
+
escapesSkipped++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
142
151
|
const prev = chosen.get(key);
|
|
143
152
|
if (!prev) {
|
|
144
153
|
chosen.set(key, { rel: r, isLink });
|
|
@@ -147,7 +156,11 @@ function walkFiles(slugDir, filter) {
|
|
|
147
156
|
duplicatesSkipped++;
|
|
148
157
|
if (prev.isLink && !isLink) chosen.set(key, { rel: r, isLink }); // real spelling wins over alias
|
|
149
158
|
}
|
|
150
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
files: [...chosen.values()].map((v) => v.rel).sort(),
|
|
161
|
+
duplicatesSkipped,
|
|
162
|
+
escapesSkipped,
|
|
163
|
+
};
|
|
151
164
|
}
|
|
152
165
|
|
|
153
166
|
const wordCount = (lines) => lines.join(' ').split(/\s+/).filter(Boolean).length;
|
|
@@ -165,7 +178,7 @@ export function docIndex(input, opts = {}) {
|
|
|
165
178
|
const sectionRef = opts.section ?? null;
|
|
166
179
|
const fileScopeArg = opts.file ?? null;
|
|
167
180
|
|
|
168
|
-
const { slugDir, repoRoot, slug } = resolveDocScope(input);
|
|
181
|
+
const { slugDir, repoRoot, slug } = resolveDocScope(input, opts.projectRoot ?? null);
|
|
169
182
|
const relFromRoot = (abs) => relative(repoRoot, abs).split(sep).join('/');
|
|
170
183
|
const relFromSlug = (abs) => relative(slugDir, abs).split(sep).join('/');
|
|
171
184
|
const parsedDocAt = makeDocCache();
|
|
@@ -324,12 +337,18 @@ export function docIndex(input, opts = {}) {
|
|
|
324
337
|
const hash = link.target.indexOf('#');
|
|
325
338
|
const target = hash === -1 ? link.target : link.target.slice(0, hash);
|
|
326
339
|
const anchor = hash === -1 ? null : link.target.slice(hash + 1);
|
|
327
|
-
|
|
340
|
+
// A link target is resolved against the document's directory, so a
|
|
341
|
+
// link like `../../../secrets.md` would otherwise point outside the
|
|
342
|
+
// artifact space. The link is checked, never READ from outside: a
|
|
343
|
+
// target outside the slug directory is reported as broken rather
|
|
344
|
+
// than followed.
|
|
345
|
+
const targetAbs = target ? resolve(dirname(doc.abs), target) : '';
|
|
346
|
+
const inside = targetAbs && (targetAbs === slugDir || targetAbs.startsWith(slugDir + sep));
|
|
347
|
+
if (target && (!inside || !existsSync(targetAbs))) {
|
|
328
348
|
broken.push({ kind: 'link', ref: link.target, file: relFromRoot(doc.abs), line: link.line });
|
|
329
349
|
continue;
|
|
330
350
|
}
|
|
331
351
|
if (anchor && target && /\.md$/i.test(target)) {
|
|
332
|
-
const targetAbs = resolve(dirname(doc.abs), target);
|
|
333
352
|
const targetDoc = parsedDocAt(targetAbs);
|
|
334
353
|
if (targetDoc && !anchorExists(targetDoc, anchor)) {
|
|
335
354
|
broken.push({ kind: 'anchor', ref: link.target, file: relFromRoot(doc.abs), line: link.line });
|
package/src/core/fs-safety.mjs
CHANGED
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
// These semantics are inherited verbatim from the CLI's store.js and from
|
|
18
18
|
// skill/scripts/set-status.mjs — the MCP server must not be weaker than the
|
|
19
19
|
// scripts it replaces.
|
|
20
|
-
import { lstatSync, mkdirSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { lstatSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { dirname, resolve, sep } from 'node:path';
|
|
22
|
-
import { permissionDenied } from './errors.mjs';
|
|
22
|
+
import { conflict, permissionDenied } from './errors.mjs';
|
|
23
23
|
|
|
24
24
|
/** Canonical refusal message — identical wording to the CLI's. */
|
|
25
25
|
export function refuseSymlinkMessage(absPath) {
|
|
@@ -42,21 +42,40 @@ export function assertNotSymlink(absPath) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* Realpath-escape guard:
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
45
|
+
* Realpath-escape guard: refuse anything that lands outside `rootAbs`.
|
|
46
|
+
*
|
|
47
|
+
* TWO CHECKS, IN ORDER.
|
|
48
|
+
*
|
|
49
|
+
* 1. LEXICAL. After normalization, the path must be inside the root. This
|
|
50
|
+
* always applies — it is the primary guard, and it cannot be skipped
|
|
51
|
+
* because a path does not exist yet.
|
|
52
|
+
* 2. REAL. The nearest existing ancestor is resolved and must also be
|
|
53
|
+
* inside the root's real path. This is what catches a symlinked
|
|
54
|
+
* PARENT directory that would redirect the write elsewhere.
|
|
55
|
+
*
|
|
56
|
+
* The guard does NOT fail open: if the root cannot be resolved, only the
|
|
57
|
+
* lexical check stands, and the lexical check is always applied.
|
|
58
|
+
*
|
|
49
59
|
* @param {string} rootAbs
|
|
50
60
|
* @param {string} absPath
|
|
51
61
|
*/
|
|
52
62
|
export function assertWithinRoot(rootAbs, absPath) {
|
|
63
|
+
const root = resolve(rootAbs);
|
|
64
|
+
const abs = resolve(absPath);
|
|
65
|
+
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
66
|
+
throw permissionDenied(
|
|
67
|
+
`refusing a path outside the project: ${absPath} is not inside ${rootAbs}`,
|
|
68
|
+
{ path: absPath, root: rootAbs },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
53
72
|
let rootReal;
|
|
54
73
|
try {
|
|
55
|
-
rootReal = realpathSync(
|
|
74
|
+
rootReal = realpathSync(root);
|
|
56
75
|
} catch {
|
|
57
|
-
return; // the root itself does not exist yet —
|
|
76
|
+
return; // the root itself does not exist yet — the lexical check stands
|
|
58
77
|
}
|
|
59
|
-
let probe =
|
|
78
|
+
let probe = abs;
|
|
60
79
|
for (;;) {
|
|
61
80
|
let exists = true;
|
|
62
81
|
try {
|
|
@@ -105,10 +124,13 @@ export function containPath(rootAbs, candidate) {
|
|
|
105
124
|
*
|
|
106
125
|
* @param {string} file absolute target path
|
|
107
126
|
* @param {string|Buffer} content
|
|
108
|
-
* @param {{eol?: string}} [opts]
|
|
127
|
+
* @param {{eol?: string, root?: string}} [opts] `root` adds a containment
|
|
128
|
+
* check for the whole parent chain, so a symlinked PARENT cannot redirect
|
|
129
|
+
* the write outside the tree.
|
|
109
130
|
*/
|
|
110
131
|
export function writeFileAtomic(file, content, opts = {}) {
|
|
111
132
|
mkdirSync(dirname(file), { recursive: true });
|
|
133
|
+
if (opts.root) assertWithinRoot(opts.root, file);
|
|
112
134
|
assertNotSymlink(file);
|
|
113
135
|
let tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
114
136
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -136,5 +158,71 @@ export function writeFileAtomic(file, content, opts = {}) {
|
|
|
136
158
|
return file;
|
|
137
159
|
}
|
|
138
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Run `fn` while holding an exclusive lock on `file`.
|
|
163
|
+
*
|
|
164
|
+
* WHY. An atomic write stops a READER seeing a half-written file. It does
|
|
165
|
+
* not stop two WRITERS losing each other's work: worker A reads tasks.yaml,
|
|
166
|
+
* worker B reads the same bytes, A writes its status change, B writes its
|
|
167
|
+
* own from the stale snapshot — and A's change is gone. The parallel
|
|
168
|
+
* workers are separate processes by design, so the guard has to live on
|
|
169
|
+
* disk, not in memory.
|
|
170
|
+
*
|
|
171
|
+
* The lock is a sibling `<file>.lock` created with O_EXCL. A lock older than
|
|
172
|
+
* `staleMs` is a crashed holder's leftover and is broken. The lock is always
|
|
173
|
+
* released, including when `fn` throws.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} file the file being mutated
|
|
176
|
+
* @param {() => any} fn
|
|
177
|
+
* @param {{timeoutMs?: number, staleMs?: number, pollMs?: number}} [opts]
|
|
178
|
+
* @returns {any} whatever `fn` returns
|
|
179
|
+
*/
|
|
180
|
+
export function withFileLock(file, fn, opts = {}) {
|
|
181
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
182
|
+
const staleMs = opts.staleMs ?? 30_000;
|
|
183
|
+
const pollMs = opts.pollMs ?? 50;
|
|
184
|
+
const lockPath = `${file}.lock`;
|
|
185
|
+
const deadline = Date.now() + timeoutMs;
|
|
186
|
+
|
|
187
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
188
|
+
for (;;) {
|
|
189
|
+
try {
|
|
190
|
+
writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
191
|
+
break;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
if (e?.code !== 'EEXIST') throw e;
|
|
194
|
+
// A lock older than staleMs belongs to a holder that died.
|
|
195
|
+
try {
|
|
196
|
+
const age = Date.now() - statSync(lockPath).mtimeMs;
|
|
197
|
+
if (age > staleMs) {
|
|
198
|
+
unlinkSync(lockPath);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
continue; // the lock vanished between the EEXIST and the stat
|
|
203
|
+
}
|
|
204
|
+
if (Date.now() > deadline) {
|
|
205
|
+
throw conflict(`timed out after ${timeoutMs}ms waiting for the lock on ${file}`, { file, lockPath });
|
|
206
|
+
}
|
|
207
|
+
sleepSync(pollMs);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
return fn();
|
|
213
|
+
} finally {
|
|
214
|
+
try {
|
|
215
|
+
unlinkSync(lockPath);
|
|
216
|
+
} catch {
|
|
217
|
+
/* the lock is already gone */
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Block the current thread for `ms` milliseconds. */
|
|
223
|
+
function sleepSync(ms) {
|
|
224
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
225
|
+
}
|
|
226
|
+
|
|
139
227
|
|
|
140
228
|
|
package/src/core/graph.mjs
CHANGED
|
@@ -78,9 +78,14 @@ export function findCycle(prerequisites) {
|
|
|
78
78
|
* @param {Map<string,any>} byId
|
|
79
79
|
* @param {Map<string,string[]>} prerequisites
|
|
80
80
|
* @returns {{waves: Array<Array<any>>, blockedByFailure: string[]}}
|
|
81
|
-
* Wave membership: every prerequisite is
|
|
82
|
-
*
|
|
83
|
-
*
|
|
81
|
+
* Wave membership: every prerequisite is DONE OR ALREADY QUEUED in an earlier
|
|
82
|
+
* wave. That is standard topological layering — a task in wave 3 may depend
|
|
83
|
+
* on a task in wave 2, which is not done yet when wave 3 starts; it is
|
|
84
|
+
* merely no longer blocked.
|
|
85
|
+
*
|
|
86
|
+
* Tasks whose TRANSITIVE prerequisites include any failed task are excluded
|
|
87
|
+
* entirely and reported in blockedByFailure (no silent cascades — the
|
|
88
|
+
* orchestrator re-plans around them). */
|
|
84
89
|
export function computeWaves(byId, prerequisites) {
|
|
85
90
|
const done = new Set();
|
|
86
91
|
const failed = new Set();
|