@codeursenior/pek 0.0.0-stage → 0.1.8

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/dist/cli.js ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { errorCode, record } from "./scan/values.js";
7
+ import { startServer } from "./server.js";
8
+ const help = `Agent Visualizer · Product Engineer Kit
9
+
10
+ Usage: npx @codeursenior/pek audit agent [folder] [options]
11
+
12
+ Explore context, skills and MCP configuration in your default browser.
13
+
14
+ --port <number> Choose a port (default: an available port)
15
+ --no-open Print the URL without opening a browser
16
+ --project-only Skip user-level assets
17
+ --help, -h Show this help
18
+ --version, -v Show the installed version
19
+
20
+ Local workspace. No telemetry. Ctrl+C to stop.
21
+ `;
22
+ try {
23
+ const { values, positionals } = parseArgs({
24
+ options: {
25
+ port: { type: "string" },
26
+ "no-open": { type: "boolean" },
27
+ "project-only": { type: "boolean" },
28
+ help: { type: "boolean", short: "h" },
29
+ version: { type: "boolean", short: "v" },
30
+ },
31
+ allowPositionals: true,
32
+ });
33
+ if (values.version) {
34
+ const { version } = record(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")));
35
+ console.log(version);
36
+ }
37
+ else if (values.help || !positionals.length) {
38
+ console.log(help);
39
+ }
40
+ else {
41
+ if (positionals[0] !== "audit" ||
42
+ positionals[1] !== "agent" ||
43
+ positionals.length > 3)
44
+ throw new Error("Expected: pek audit agent [folder]. Use --help for options.");
45
+ const port = values.port === undefined ? 0 : Number(values.port);
46
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
47
+ throw new Error("Port must be an integer between 0 and 65535.");
48
+ const root = path.resolve(positionals[2] || process.cwd());
49
+ console.log(`\n Agent Visualizer\n Scanning ${root} ...`);
50
+ const { server, url } = await startServer({
51
+ root,
52
+ port,
53
+ includeUser: !values["project-only"],
54
+ });
55
+ console.log(`\n ${url}\n\n Local workspace · Ctrl+C to stop\n`);
56
+ if (!values["no-open"]) {
57
+ const command = process.platform === "darwin"
58
+ ? "open"
59
+ : process.platform === "win32"
60
+ ? "rundll32"
61
+ : "xdg-open";
62
+ const args = process.platform === "win32"
63
+ ? ["url.dll,FileProtocolHandler", url]
64
+ : [url];
65
+ const child = spawn(command, args, {
66
+ stdio: "ignore",
67
+ detached: true,
68
+ shell: false,
69
+ });
70
+ child.on("error", () => console.log(" Open the URL above in your browser."));
71
+ child.on("exit", (code) => {
72
+ if (code)
73
+ console.log(" Open the URL above in your browser.");
74
+ });
75
+ child.unref();
76
+ }
77
+ for (const signal of ["SIGINT", "SIGTERM"])
78
+ process.once(signal, () => {
79
+ server.close();
80
+ server.closeAllConnections();
81
+ });
82
+ }
83
+ }
84
+ catch (error) {
85
+ console.error(`\n ${errorCode(error) === "EADDRINUSE" ? "Port is in use. Choose another --port or omit it." : error instanceof Error ? error.message : String(error)}\n`);
86
+ process.exitCode = 1;
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const types = {
4
+ ".html": "text/html; charset=utf-8",
5
+ ".js": "text/javascript; charset=utf-8",
6
+ ".css": "text/css; charset=utf-8",
7
+ ".svg": "image/svg+xml",
8
+ ".png": "image/png",
9
+ ".jpg": "image/jpeg",
10
+ ".webp": "image/webp",
11
+ ".ico": "image/x-icon",
12
+ ".txt": "text/plain; charset=utf-8",
13
+ ".woff2": "font/woff2",
14
+ };
15
+ /** Enumerate build output once. Request paths are map keys, never filesystem paths. */
16
+ export async function browserAssets(directory) {
17
+ const assets = new Map();
18
+ async function visit(relative) {
19
+ for (const entry of await fs.readdir(path.join(directory, relative), {
20
+ withFileTypes: true,
21
+ })) {
22
+ const name = path.posix.join(relative, entry.name);
23
+ if (entry.isDirectory())
24
+ await visit(name);
25
+ else if (entry.isFile()) {
26
+ const type = types[path.extname(name)];
27
+ if (type)
28
+ assets.set(name === "index.html" ? "/" : "/" + name, {
29
+ file: path.join(directory, name),
30
+ type,
31
+ });
32
+ }
33
+ }
34
+ }
35
+ try {
36
+ await visit("");
37
+ }
38
+ catch {
39
+ /* Missing build is reported on requests. */
40
+ }
41
+ return assets;
42
+ }
@@ -0,0 +1,7 @@
1
+ export class HttpError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.status = status;
6
+ }
7
+ }
@@ -0,0 +1,72 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import { record } from "../scan/values.js";
4
+ import { HttpError } from "./errors.js";
5
+ const MAX_EDIT_BYTES = 512 * 1024;
6
+ const revision = (text) => createHash("sha256").update(text).digest("hex");
7
+ async function readEditBody(req, deleting) {
8
+ if (!req.headers["content-type"]?.startsWith("application/json"))
9
+ throw new HttpError(415, "Expected JSON.");
10
+ const chunks = [];
11
+ let bytes = 0;
12
+ for await (const raw of req) {
13
+ const chunk = raw;
14
+ if (!Buffer.isBuffer(chunk))
15
+ throw new HttpError(400, "Invalid JSON.");
16
+ bytes += chunk.length;
17
+ if (bytes > MAX_EDIT_BYTES * 2)
18
+ throw new HttpError(413, "File is too large to edit.");
19
+ chunks.push(chunk);
20
+ }
21
+ let body;
22
+ try {
23
+ body = record(JSON.parse(Buffer.concat(chunks).toString("utf8")));
24
+ }
25
+ catch {
26
+ throw new HttpError(400, "Invalid JSON.");
27
+ }
28
+ if (typeof body.revision !== "string" ||
29
+ (!deleting && typeof body.text !== "string")) {
30
+ throw new HttpError(400, deleting ? "Missing revision." : "Missing text or revision.");
31
+ }
32
+ const text = typeof body.text === "string" ? body.text : "";
33
+ if (!deleting && Buffer.byteLength(text) > MAX_EDIT_BYTES)
34
+ throw new HttpError(413, "File is too large to edit.");
35
+ return { revision: body.revision, text };
36
+ }
37
+ async function readCurrent(target) {
38
+ if ((await fs.realpath(target.file)) !== target.real)
39
+ throw new HttpError(409, "File path changed. Rescan before editing.");
40
+ const stat = await fs.stat(target.real);
41
+ if (!stat.isFile() || stat.size > MAX_EDIT_BYTES)
42
+ throw new HttpError(413, "File is too large to edit.");
43
+ return fs.readFile(target.real, "utf8");
44
+ }
45
+ /** One write queue per server; failed writes must not poison subsequent requests. */
46
+ export class FileOperations {
47
+ saving = Promise.resolve();
48
+ async handle(req, target) {
49
+ if (req.method === "GET") {
50
+ const text = await readCurrent(target);
51
+ return { text, revision: revision(text) };
52
+ }
53
+ const deleting = req.method === "DELETE";
54
+ const body = await readEditBody(req, deleting);
55
+ const save = this.saving.then(async () => {
56
+ const current = await readCurrent(target);
57
+ if (revision(current) !== body.revision)
58
+ throw new HttpError(409, "File changed on disk. Reopen it before changing it.");
59
+ if (deleting) {
60
+ if (!target.deletable ||
61
+ (await fs.lstat(target.file)).isSymbolicLink())
62
+ throw new HttpError(409, "This file has another path. Rescan before deleting.");
63
+ await fs.unlink(target.file);
64
+ return { deleted: true };
65
+ }
66
+ await fs.writeFile(target.real, body.text, "utf8");
67
+ return { revision: revision(body.text) };
68
+ });
69
+ this.saving = save.catch(() => undefined);
70
+ return save;
71
+ }
72
+ }
@@ -0,0 +1,16 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ export function localRequest(req, host) {
3
+ return (req.headers.host === host &&
4
+ (!req.headers.origin || req.headers.origin === `http://${host}`));
5
+ }
6
+ export function authenticated(req, token) {
7
+ const provided = Buffer.from(req.headers.authorization?.replace(/^Bearer /, "") || "");
8
+ const expected = Buffer.from(token);
9
+ return (provided.length === expected.length && timingSafeEqual(provided, expected));
10
+ }
11
+ export function securityHeaders(res, nonce) {
12
+ res.setHeader("Cache-Control", "no-store");
13
+ res.setHeader("X-Content-Type-Options", "nosniff");
14
+ res.setHeader("Referrer-Policy", "no-referrer");
15
+ res.setHeader("Content-Security-Policy", `default-src 'self'; script-src 'self'; style-src 'self' 'nonce-${nonce}'; img-src 'self' data:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`);
16
+ }
@@ -0,0 +1,57 @@
1
+ import { execFile } from "node:child_process";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { inside } from "./scan/values.js";
5
+ /** Fixed read-only probes only; never execute commands from workspace metadata. */
6
+ export async function optimizationChecks(root) {
7
+ const probe = (argument) => new Promise((resolve, reject) => {
8
+ const searchPath = (process.env.PATH ?? "")
9
+ .split(path.delimiter)
10
+ .filter((directory) => path.isAbsolute(directory) && !inside(root, directory))
11
+ .join(path.delimiter);
12
+ execFile("rtk", [argument], {
13
+ cwd: os.tmpdir(),
14
+ env: { ...process.env, PATH: searchPath },
15
+ timeout: 3000,
16
+ maxBuffer: 64 * 1024,
17
+ encoding: "utf8",
18
+ windowsHide: true,
19
+ }, (error, stdout) => (error ? reject(error) : resolve(stdout)));
20
+ });
21
+ let detected = false;
22
+ let detail = "Rust Token Killer was not detected on the server's PATH. Install it in your terminal, then check again.";
23
+ try {
24
+ const version = await probe("--version");
25
+ const help = await probe("-h");
26
+ detected =
27
+ /^rtk\s+\d+\.\d+\.\d+/m.test(version) && /Rust Token Killer/i.test(help);
28
+ detail = detected
29
+ ? "Rust Token Killer installation detected. Agent integration is not verified."
30
+ : "An rtk executable responded, but its Rust Token Killer identity could not be verified.";
31
+ }
32
+ catch {
33
+ /* Missing, inaccessible, or timed-out executable remains unchecked. */
34
+ }
35
+ return {
36
+ checks: [
37
+ {
38
+ id: "rtk",
39
+ label: "Install RTK",
40
+ detected,
41
+ detail,
42
+ guide: "https://www.rtk-ai.app/docs/getting-started/installation/",
43
+ instructions: [
44
+ {
45
+ label: "macOS / Linux (Homebrew)",
46
+ command: "brew install rtk-ai/tap/rtk",
47
+ },
48
+ { label: "Windows", command: "winget install rtk-ai.rtk" },
49
+ {
50
+ label: "Cargo",
51
+ command: "cargo install --git https://github.com/rtk-ai/rtk --branch master rtk",
52
+ },
53
+ ],
54
+ },
55
+ ],
56
+ };
57
+ }
@@ -0,0 +1,171 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parse as yaml } from "yaml";
4
+ import { clientsForContextEntry, frontmatter, ruleClient, toolsForSkill, } from "./metadata.js";
5
+ import { falsehood, id, inside, INSTRUCTION, lineCount, record, required, slash, truth, } from "./values.js";
6
+ export async function discoverAssets(entries, env) {
7
+ const { root, codexHome, display, warnings } = env;
8
+ const context = new Map();
9
+ const rules = new Map();
10
+ const skills = new Map();
11
+ const content = new Map();
12
+ const editable = new Map();
13
+ function addContext(entry) {
14
+ if (context.has(entry.id))
15
+ return;
16
+ const aliases = entry.aliases.filter((p) => !ruleClient(p));
17
+ const firstAlias = aliases[0];
18
+ if (!firstAlias)
19
+ return;
20
+ const deletable = entry.aliases.length === 1 &&
21
+ path.relative(entry.file, entry.real) === "";
22
+ const paths = aliases.map((p) => display(p, entry.scope));
23
+ const kind = aliases.some((p) => INSTRUCTION.test(path.basename(p)))
24
+ ? "instruction"
25
+ : aliases.some((p) => /[\\/]rules[\\/]|\.mdc$/.test(p))
26
+ ? "rule"
27
+ : "knowledge";
28
+ context.set(entry.id, {
29
+ id: entry.id,
30
+ name: path.basename(firstAlias),
31
+ path: required(paths[0]),
32
+ aliases: paths,
33
+ clients: [
34
+ ...new Set(aliases.flatMap((alias) => clientsForContextEntry(alias, entry.scope, root, codexHome))),
35
+ ],
36
+ editTargets: [{ id: entry.id, path: required(paths[0]), deletable }],
37
+ scope: entry.scope,
38
+ kind,
39
+ bytes: Buffer.byteLength(entry.text),
40
+ lines: lineCount(entry.text),
41
+ excerpt: entry.text.slice(0, 180),
42
+ });
43
+ content.set(entry.id, entry.text);
44
+ editable.set(entry.id, { file: entry.file, real: entry.real, deletable });
45
+ }
46
+ for (const entry of entries) {
47
+ if (path.basename(entry.file) === "SKILL.md") {
48
+ const deletable = entry.aliases.length === 1 &&
49
+ path.relative(entry.file, entry.real) === "";
50
+ let meta = {};
51
+ try {
52
+ meta = frontmatter(entry.text);
53
+ }
54
+ catch {
55
+ warnings.push(`Invalid skill frontmatter: ${display(entry.file, entry.scope)}`);
56
+ }
57
+ const name = typeof meta.name === "string"
58
+ ? meta.name
59
+ : path.basename(path.dirname(entry.file));
60
+ const key = `${entry.scope}:${name}:${id(entry.text)}`;
61
+ if (!skills.has(key))
62
+ skills.set(key, {
63
+ id: id(key),
64
+ name,
65
+ description: typeof meta.description === "string" ? meta.description : "",
66
+ scope: entry.scope,
67
+ paths: [],
68
+ editTargets: [],
69
+ clients: [],
70
+ invocation: {},
71
+ legacy: false,
72
+ mode: "Not discovered",
73
+ });
74
+ const skill = required(skills.get(key));
75
+ content.set(skill.id, entry.text);
76
+ editable.set(entry.id, { file: entry.file, real: entry.real, deletable });
77
+ skill.editTargets.push({
78
+ id: entry.id,
79
+ path: display(entry.file, entry.scope),
80
+ deletable,
81
+ });
82
+ let policy = {};
83
+ try {
84
+ policy = record(record(yaml(await fs.readFile(path.join(path.dirname(entry.real), "agents/openai.yaml"), "utf8"))).policy);
85
+ }
86
+ catch {
87
+ /* Optional metadata. */
88
+ }
89
+ for (const alias of entry.aliases) {
90
+ const p = display(alias, entry.scope);
91
+ skill.paths.push(p);
92
+ if (p.includes(".codex/skills/"))
93
+ skill.legacy = true;
94
+ const discovered = toolsForSkill(slash(alias));
95
+ if (entry.scope === "user" &&
96
+ inside(path.join(codexHome, "skills"), alias) &&
97
+ !discovered.includes("codex"))
98
+ discovered.push("codex");
99
+ for (const client of discovered) {
100
+ if (!skill.clients.includes(client))
101
+ skill.clients.push(client);
102
+ const invocation = client === "codex"
103
+ ? falsehood(policy.allow_implicit_invocation)
104
+ ? "User only"
105
+ : "Model + user"
106
+ : truth(meta["disable-model-invocation"])
107
+ ? falsehood(meta["user-invocable"])
108
+ ? "Disabled"
109
+ : "User only"
110
+ : falsehood(meta["user-invocable"]) &&
111
+ (client === "claude" || client === "copilot")
112
+ ? "Model only"
113
+ : "Model + user";
114
+ skill.invocation[client] = [
115
+ ...new Set([
116
+ ...(skill.invocation[client]?.split(" / ") || []),
117
+ invocation,
118
+ ]),
119
+ ].join(" / ");
120
+ }
121
+ }
122
+ skill.mode =
123
+ [...new Set(Object.values(skill.invocation))].join(" / ") ||
124
+ "Not discovered";
125
+ }
126
+ else {
127
+ const ruleAliases = entry.aliases.filter((p) => ruleClient(p));
128
+ if (ruleAliases.length) {
129
+ let meta = {};
130
+ try {
131
+ meta = frontmatter(entry.text);
132
+ }
133
+ catch {
134
+ warnings.push(`Invalid rule frontmatter: ${display(entry.file, entry.scope)}`);
135
+ }
136
+ const patterns = (value) => (Array.isArray(value)
137
+ ? value
138
+ : typeof value === "string"
139
+ ? [value]
140
+ : [])
141
+ .filter((item) => typeof item === "string")
142
+ .map((item) => item.trim())
143
+ .filter(Boolean);
144
+ rules.set(entry.id, {
145
+ id: entry.id,
146
+ name: path.basename(required(ruleAliases[0])),
147
+ description: typeof meta.description === "string" ? meta.description : "",
148
+ scope: entry.scope,
149
+ paths: ruleAliases.map((p) => display(p, entry.scope)),
150
+ clients: [
151
+ ...new Set(ruleAliases
152
+ .map(ruleClient)
153
+ .filter((client) => client !== null)),
154
+ ],
155
+ legacy: ruleAliases.some((p) => path.basename(p).toLowerCase() === ".cursorrules"),
156
+ globs: patterns(meta.globs),
157
+ pathsCondition: patterns(meta.paths),
158
+ applyTo: patterns(meta.applyTo),
159
+ alwaysApply: meta.alwaysApply === true,
160
+ });
161
+ content.set(entry.id, entry.text);
162
+ }
163
+ if (!entry.aliases.every((p) => /[\\/]skills[\\/]/.test(p)) &&
164
+ entry.aliases.some((p) => !ruleClient(p) &&
165
+ (INSTRUCTION.test(path.basename(p)) ||
166
+ /[\\/]\.(agents|claude|cursor|codex)[\\/](knowledge|rules)[\\/]/.test(p))))
167
+ addContext(entry);
168
+ }
169
+ }
170
+ return { context, rules, skills, content, editable, addContext };
171
+ }
@@ -0,0 +1,107 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { errorCode, id, inside, INSTRUCTION, MARKDOWN, MAX_BYTES, MAX_FILES, required, } from "./values.js";
4
+ const SKIP = new Set([
5
+ "node_modules",
6
+ ".git",
7
+ ".local",
8
+ "dist",
9
+ "build",
10
+ "coverage",
11
+ ".next",
12
+ ".cache",
13
+ ".angular",
14
+ ".venv",
15
+ "vendor",
16
+ "target",
17
+ "plugins",
18
+ "sessions",
19
+ "history",
20
+ ]);
21
+ const AGENT_DIRS = [".agents", ".claude", ".codex", ".cursor", ".github"];
22
+ export async function collectFiles(env) {
23
+ const { root, home, codexHome, includeUser, allowed, display, warnings } = env;
24
+ const userBases = allowed.slice(1);
25
+ const files = new Map();
26
+ const projectConfigs = new Set();
27
+ let inspected = 0;
28
+ let capped = false;
29
+ async function collect(file, scope, ancestors = new Set(), depth = 0) {
30
+ if (depth > 35 || inspected >= MAX_FILES) {
31
+ capped = true;
32
+ return;
33
+ }
34
+ try {
35
+ const real = await fs.realpath(file);
36
+ if (!allowed.some((base) => inside(base, real)))
37
+ return;
38
+ const stat = await fs.stat(real);
39
+ if (stat.isDirectory()) {
40
+ if (ancestors.has(real))
41
+ return;
42
+ const next = new Set(ancestors).add(real);
43
+ const entries = await fs.readdir(file, { withFileTypes: true });
44
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
45
+ if (path.basename(file) === ".github" &&
46
+ ["agents", "prompts"].includes(entry.name))
47
+ continue;
48
+ if (SKIP.has(entry.name) ||
49
+ (entry.name.startsWith(".") &&
50
+ entry.isDirectory() &&
51
+ !AGENT_DIRS.includes(entry.name) &&
52
+ entry.name !== ".system" &&
53
+ entry.name !== ".vscode"))
54
+ continue;
55
+ await collect(path.join(file, entry.name), scope, next, depth + 1);
56
+ }
57
+ }
58
+ else if (stat.isFile()) {
59
+ inspected++;
60
+ if (scope === "project" &&
61
+ /(?:^|[\\/])(?:\.mcp\.json|\.cursor[\\/]mcp\.json|\.codex[\\/]config\.toml|\.vscode[\\/]mcp\.json)$/.test(file))
62
+ projectConfigs.add(file);
63
+ if (!MARKDOWN.test(file) && !INSTRUCTION.test(path.basename(file)))
64
+ return;
65
+ if (stat.size > MAX_BYTES) {
66
+ warnings.push(`Skipped large context file: ${display(file, scope)}`);
67
+ return;
68
+ }
69
+ const key = `${scope}:${real}`;
70
+ if (files.has(key)) {
71
+ required(files.get(key)).aliases.push(file);
72
+ return;
73
+ }
74
+ files.set(key, {
75
+ id: id(key),
76
+ real,
77
+ file,
78
+ aliases: [file],
79
+ scope,
80
+ text: await fs.readFile(real, "utf8"),
81
+ });
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (!["ENOENT", "ENOTDIR"].includes(errorCode(error) ?? ""))
86
+ warnings.push(`Cannot read ${display(file, scope)} (${errorCode(error) || "invalid file"}).`);
87
+ }
88
+ }
89
+ await collect(root, "project");
90
+ if (includeUser) {
91
+ for (const base of userBases) {
92
+ for (const child of ["skills", "knowledge", "rules", "instructions"])
93
+ await collect(path.join(base, child), "user");
94
+ }
95
+ for (const file of [
96
+ path.join(home, ".claude", "CLAUDE.md"),
97
+ path.join(codexHome, "AGENTS.md"),
98
+ path.join(codexHome, "AGENTS.override.md"),
99
+ path.join(home, ".cursor", "AGENTS.md"),
100
+ path.join(home, ".copilot", "copilot-instructions.md"),
101
+ ])
102
+ await collect(file, "user");
103
+ }
104
+ if (capped)
105
+ warnings.push(`Scan stopped at ${MAX_FILES} files or 35 folder levels. Some assets may be missing.`);
106
+ return { entries: [...files.values()], projectConfigs, inspected, capped };
107
+ }
@@ -0,0 +1,71 @@
1
+ import path from "node:path";
2
+ import { referenceResolver } from "./references.js";
3
+ import { links, ruleClient } from "./metadata.js";
4
+ import { inside, required } from "./values.js";
5
+ export function buildGraph(entries, context, addContext, env) {
6
+ const edges = new Map();
7
+ const resolveReference = referenceResolver(entries, env);
8
+ // Follow references from context roots, never turn the entire codebase into a graph.
9
+ const queue = entries.filter((entry) => context.has(entry.id));
10
+ const visited = new Set();
11
+ for (let i = 0; i < queue.length; i++) {
12
+ const entry = required(queue[i]);
13
+ if (visited.has(entry.id))
14
+ continue;
15
+ visited.add(entry.id);
16
+ for (const link of links(entry.text)) {
17
+ const found = resolveReference(entry, link);
18
+ if (found &&
19
+ path.basename(found.file) !== "SKILL.md" &&
20
+ found.aliases.some((p) => !ruleClient(p))) {
21
+ addContext(found);
22
+ if (context.has(found.id))
23
+ queue.push(found);
24
+ if (context.has(found.id) && found.id !== entry.id)
25
+ edges.set(`${entry.id}:${found.id}`, {
26
+ source: entry.id,
27
+ target: found.id,
28
+ kind: "reference",
29
+ });
30
+ }
31
+ }
32
+ }
33
+ // Dashed hierarchy edges are distinct from explicit document references.
34
+ const instructions = entries.filter((e) => context.get(e.id)?.kind === "instruction");
35
+ for (const child of instructions) {
36
+ const parents = instructions.filter((p) => p.id !== child.id &&
37
+ p.scope === child.scope &&
38
+ path.dirname(p.file) !== path.dirname(child.file) &&
39
+ inside(path.dirname(p.file), child.file));
40
+ parents.sort((a, b) => b.file.length - a.file.length);
41
+ if (parents[0])
42
+ edges.set(`scope:${parents[0].id}:${child.id}`, {
43
+ source: parents[0].id,
44
+ target: child.id,
45
+ kind: "scope",
46
+ });
47
+ }
48
+ // Only explicit references carry discovery to other documents. Folder scope
49
+ // describes where instructions apply; it does not import the child file.
50
+ const references = new Map();
51
+ for (const edge of edges.values()) {
52
+ if (edge.kind !== "reference")
53
+ continue;
54
+ if (!references.has(edge.source))
55
+ references.set(edge.source, []);
56
+ required(references.get(edge.source)).push(edge.target);
57
+ }
58
+ const pending = [...context.values()].filter((node) => node.clients.length);
59
+ for (let i = 0; i < pending.length; i++) {
60
+ const source = required(pending[i]);
61
+ for (const targetId of references.get(source.id) || []) {
62
+ const target = required(context.get(targetId));
63
+ const added = source.clients.filter((client) => !target.clients.includes(client));
64
+ if (!added.length)
65
+ continue;
66
+ target.clients.push(...added);
67
+ pending.push(target);
68
+ }
69
+ }
70
+ return [...edges.values()];
71
+ }