agent-hub-runner 0.2.2 → 0.3.1

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 CHANGED
@@ -1,3 +1,5 @@
1
+ <!-- @意图#R747(2026-11-06): 说明 .claude/skills 需运行器 0.3.1 -->
2
+ <!-- @意图#R745(2026-11-06): 说明 context_snapshot / 0.3.0 -->
1
3
  <!-- @意图#R729(2026-11-04): 加 browser install / status 和「bot 的浏览器」一节(0.2.0) -->
2
4
  <!-- @意图#R690(2026-11-01): 加「改动记录」一节:存在哪、打码、64 KB、30 天、怎么删 -->
3
5
  <!-- @意图#R732(2026-11-04): 常见问题加「升级了网页还说版本太旧」(0.2.2 起新版自动顶掉旧进程) -->
@@ -65,6 +67,11 @@ npx -y agent-hub-runner@latest browser status # 看浏览器装没装
65
67
  - 想放别处:设环境变量 `AGENT_RUNNER_CONFIG=/某个路径/config.json`。
66
68
  - 文件坏了:删掉它,回网页拿新配对码重新配对。
67
69
 
70
+
71
+ ## 项目上下文快照(0.3.0;`.claude/skills` 起 0.3.1)
72
+
73
+ 服务器在每轮主对话开始时可能调用只读操作 `context_snapshot`(没有路径参数):在**当前 bot 的授权文件夹根**里读取一份 AGENTS/CLAUDE 规则,并列出 `.pi/skills`、`.agents/skills`、`.claude/skills` 下技能的名称与说明(不上传技能全文)。目录外路径和越界符号链接一律拒绝。基础快照需要运行器 **0.3.0+**;发现 `.claude/skills` 需要 **0.3.1+**。
74
+
68
75
  ## 常见问题
69
76
 
70
77
  - `command not found: npx`:还没装 Node。
package/autostart.mjs CHANGED
@@ -20,7 +20,7 @@ export const THROTTLE_S = 10;
20
20
  /** 复制到 ~/.agent-runner/bin/<版本>/ 的文件(package.json 用来读版本号) */
21
21
  // @意图#R729(2026-11-04): 多复制 browser.mjs(runner.mjs 要 import 它)
22
22
  // @意图#R690(2026-11-01): 多复制 changes.mjs(runner.mjs 要 import 它,少了开机自启的副本起不来)
23
- export const COPY_FILES = ["runner.mjs", "ops.mjs", "edit.mjs", "changes.mjs", "browser.mjs", "autostart.mjs", "package.json"];
23
+ export const COPY_FILES = ["runner.mjs", "ops.mjs", "project-context.mjs", "edit.mjs", "changes.mjs", "browser.mjs", "autostart.mjs", "package.json"];
24
24
  /** 优先用的稳定 node 路径(Homebrew 升级 node 后这两个软链还在;nvm 的带版本号路径卸了就没了) */
25
25
  export const STABLE_NODES = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
26
26
  export const ONLY_MAC = "开机自启目前只支持 Mac 和 Windows";
package/ops.mjs CHANGED
@@ -6,6 +6,7 @@ import fs from "node:fs/promises";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { editText } from "./edit.mjs";
9
+ import { opContextSnapshot } from "./project-context.mjs";
9
10
 
10
11
  export const MAX_READ_BYTES = 20 * 1024 * 1024;
11
12
  /** bash 没给 timeout 时的默认上限(秒),到点杀掉整个进程组。 */
@@ -183,12 +184,15 @@ export function opBash(args, root, { onData, signal, platform = process.platform
183
184
 
184
185
  /** 按 op 分派;未知 op 拒绝。 */
185
186
  // @意图#R690(2026-11-01): write、edit 也收 extra(onChange 存改动记录)
186
- // @意图#R636(2026-10-27): 协议里只有这四种工具调用
187
+ // @意图#R745(2026-11-06): 另加只读 context_snapshot(无路径入参)
188
+ // @意图#R636(2026-10-27): 协议里原有四种工具调用
189
+ // @意图#R745(2026-11-06): 从客户已授权目录加载项目规则并按需提供技能
187
190
  export function runOp(op, args, root, extra) {
188
191
  const a = args && typeof args === "object" ? args : {};
189
192
  if (op === "read") return opRead(a, root);
190
193
  if (op === "write") return opWrite(a, root, extra);
191
194
  if (op === "edit") return opEdit(a, root, extra);
192
195
  if (op === "bash") return opBash(a, root, extra);
196
+ if (op === "context_snapshot") return opContextSnapshot(a, root); // 固定扫授权根,忽略路径参数
193
197
  return Promise.reject(new Error(`未知操作 ${op}`));
194
198
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub-runner",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准),还能用它自己的独立浏览器",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "runner.mjs",
11
11
  "ops.mjs",
12
+ "project-context.mjs",
12
13
  "edit.mjs",
13
14
  "changes.mjs",
14
15
  "browser.mjs",
@@ -0,0 +1,402 @@
1
+ // @意图#R747(2026-11-06): 授权根增加 .claude/skills,兼容 Claude Code 项目技能目录
2
+ // @意图#R745(2026-11-06): 从客户已授权目录加载项目规则并按需提供技能
3
+ // 只扫授权根:一份 AGENTS/CLAUDE 候选 + .pi/skills + .agents/skills + .claude/skills;不读祖先、不读 ~/.agents / ~/.pi / ~/.claude。
4
+ import fs from "node:fs/promises";
5
+ import { existsSync, readFileSync, readdirSync, statSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
6
+ import path from "node:path";
7
+
8
+ // 路径校验与 ops.mjs 同语义,避免与 ops 循环依赖。
9
+ export const inside = (real, root) => real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
10
+
11
+ export async function realOf(abs, depth = 0) {
12
+ if (depth > 40) throw new Error("符号链接太多层");
13
+ try {
14
+ return await fs.realpath(abs);
15
+ } catch (e) {
16
+ if (e?.code !== "ENOENT") throw e;
17
+ let st;
18
+ try { st = lstatSync(abs); } catch { st = undefined; }
19
+ if (st?.isSymbolicLink()) {
20
+ const target = path.resolve(path.dirname(abs), readlinkSync(abs));
21
+ return realOf(target, depth + 1);
22
+ }
23
+ const parent = path.dirname(abs);
24
+ if (parent === abs) throw e;
25
+ return path.join(await realOf(parent, depth + 1), path.basename(abs));
26
+ }
27
+ }
28
+
29
+ export const AGENTS_CANDIDATES = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
30
+ export const MAX_TOTAL_BYTES = 2 * 1024 * 1024;
31
+ export const MAX_FILE_BYTES = 128 * 1024;
32
+ export const MAX_SKILLS = 100;
33
+ export const SKILL_DIRS = [".pi/skills", ".agents/skills", ".claude/skills"];
34
+ export const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
35
+
36
+ export function stripBom(s) {
37
+ return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
38
+ }
39
+
40
+ function toPosix(p) {
41
+ return p.split(path.sep).join("/");
42
+ }
43
+
44
+ /** 极简 ignore:注释、否定、末尾 /、* 与 **(不含完整 gitignore 语义,够 pi 技能树常用规则)。 */
45
+ export function createIgnoreMatcher() {
46
+ const rules = [];
47
+ return {
48
+ add(patterns) {
49
+ for (const raw of patterns) {
50
+ const line = String(raw);
51
+ const trimmed = line.trim();
52
+ if (!trimmed || (trimmed.startsWith("#") && !trimmed.startsWith("\\#"))) continue;
53
+ let negated = false;
54
+ let pattern = line;
55
+ if (pattern.startsWith("!")) { negated = true; pattern = pattern.slice(1); }
56
+ else if (pattern.startsWith("\\!")) pattern = pattern.slice(1);
57
+ if (pattern.startsWith("/")) pattern = pattern.slice(1);
58
+ rules.push({ negated, pattern: pattern.replace(/\\/g, "/"), dirOnly: pattern.endsWith("/") });
59
+ }
60
+ },
61
+ ignores(rel, isDir = false) {
62
+ const target = toPosix(rel).replace(/^\/+/, "");
63
+ let ignored = false;
64
+ for (const r of rules) {
65
+ const pat = r.dirOnly ? r.pattern.slice(0, -1) : r.pattern;
66
+ if (r.dirOnly && !isDir) continue;
67
+ if (matchGlob(pat, target) || matchGlob(pat, target.split("/").pop() || "")) {
68
+ ignored = !r.negated;
69
+ }
70
+ }
71
+ return ignored;
72
+ },
73
+ };
74
+ }
75
+
76
+ function matchGlob(pat, text) {
77
+ if (pat === text) return true;
78
+ if (pat.endsWith("/**")) {
79
+ const base = pat.slice(0, -3);
80
+ return text === base || text.startsWith(base + "/");
81
+ }
82
+ if (pat.includes("**")) {
83
+ const parts = pat.split("**");
84
+ if (parts.length !== 2) return false;
85
+ const [a, b] = parts;
86
+ if (a && !text.startsWith(a.replace(/\/$/, ""))) {
87
+ if (!text.includes("/" + a.replace(/^\/|\/$/g, ""))) return false;
88
+ }
89
+ const rest = a ? text.slice(Math.max(text.indexOf(a.replace(/\/$/, "")) + a.replace(/\/$/, "").length, 0)) : text;
90
+ const needle = b.replace(/^\//, "");
91
+ return !needle || rest.includes(needle) || rest.endsWith(needle.replace(/^\//, ""));
92
+ }
93
+ if (!pat.includes("*") && !pat.includes("?")) {
94
+ return text === pat || text.endsWith("/" + pat);
95
+ }
96
+ const esc = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
97
+ return new RegExp(`(?:^|/)${esc}$`).test(text) || new RegExp(`^${esc}$`).test(text);
98
+ }
99
+
100
+ function addIgnoreRules(ig, dir, root) {
101
+ const relativeDir = path.relative(root, dir);
102
+ const prefix = relativeDir && relativeDir !== "." ? `${toPosix(relativeDir)}/` : "";
103
+ for (const filename of IGNORE_FILE_NAMES) {
104
+ const ignorePath = path.join(dir, filename);
105
+ if (!existsSync(ignorePath)) continue;
106
+ try {
107
+ const content = readFileSync(ignorePath, "utf8");
108
+ const patterns = content.split(/\r?\n/).map((line) => {
109
+ const trimmed = line.trim();
110
+ if (!trimmed || (trimmed.startsWith("#") && !trimmed.startsWith("\\#"))) return null;
111
+ let pattern = line;
112
+ let negated = false;
113
+ if (pattern.startsWith("!")) { negated = true; pattern = pattern.slice(1); }
114
+ else if (pattern.startsWith("\\!")) pattern = pattern.slice(1);
115
+ if (pattern.startsWith("/")) pattern = pattern.slice(1);
116
+ const prefixed = prefix ? `${prefix}${pattern}` : pattern;
117
+ return negated ? `!${prefixed}` : prefixed;
118
+ }).filter(Boolean);
119
+ if (patterns.length) ig.add(patterns);
120
+ } catch { /* ignore unreadable */ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * 小型 frontmatter:只认 name / description / disable-model-invocation(及 camelCase)。
126
+ * 支持单双引号、| / > / |- / >- / |> / >| 多行块。
127
+ */
128
+ export function parseSkillFrontmatter(raw) {
129
+ const text = stripBom(String(raw ?? ""));
130
+ if (!text.startsWith("---")) return null;
131
+ const nl = text.indexOf("\n");
132
+ if (nl < 0) return null;
133
+ const end = text.indexOf("\n---", nl);
134
+ if (end < 0) return null;
135
+ const body = text.slice(nl + 1, end);
136
+ const out = {};
137
+ const lines = body.split(/\r?\n/);
138
+ let i = 0;
139
+ while (i < lines.length) {
140
+ const line = lines[i];
141
+ const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
142
+ if (!m) { i += 1; continue; }
143
+ const key = m[1];
144
+ let val = m[2];
145
+ if (val === "" || /^[|>](?:[+-]\d?|\d[+-]?)$/.test(val) || val === "|>" || val === ">|") {
146
+ const block = [];
147
+ i += 1;
148
+ while (i < lines.length) {
149
+ const L = lines[i];
150
+ if (L === "" || L.startsWith(" ") || L.startsWith("\t")) {
151
+ block.push(L.replace(/^ /, "").replace(/^\t/, ""));
152
+ i += 1;
153
+ continue;
154
+ }
155
+ if (/^[A-Za-z0-9_-]+\s*:/.test(L)) break;
156
+ block.push(L);
157
+ i += 1;
158
+ }
159
+ while (block.length && block[block.length - 1] === "") block.pop();
160
+ out[key] = block.join("\n");
161
+ continue;
162
+ }
163
+ if (val.startsWith('"')) {
164
+ // 双引号:优先按 JSON 字符串解(支持 \" \\ \n);失败再剥一层皮
165
+ try { out[key] = JSON.parse(val); }
166
+ catch {
167
+ out[key] = val.length >= 2 && val.endsWith('"') ? val.slice(1, -1) : val.slice(1);
168
+ }
169
+ } else if (val.startsWith("'")) {
170
+ // 单引号:YAML 里 '' 表示一个 '
171
+ let s = val.endsWith("'") ? val.slice(1, -1) : val.slice(1);
172
+ out[key] = s.replace(/''/g, "'");
173
+ } else if (val === "true") out[key] = true;
174
+ else if (val === "false") out[key] = false;
175
+ else out[key] = val;
176
+ i += 1;
177
+ }
178
+ return out;
179
+ }
180
+
181
+ function skillFromFile(filePath) {
182
+ let raw;
183
+ try { raw = readFileSync(filePath, "utf8"); } catch { return { skill: null, notice: `无法读取技能文件:${filePath}` }; }
184
+ let bytes;
185
+ try { bytes = Buffer.byteLength(raw, "utf8"); } catch { bytes = raw.length; }
186
+ if (bytes > MAX_FILE_BYTES) {
187
+ return { skill: null, notice: `技能文件超过 ${MAX_FILE_BYTES} 字节,已跳过:${filePath}` };
188
+ }
189
+ const fm = parseSkillFrontmatter(raw);
190
+ const declared = path.basename(filePath) === "SKILL.md";
191
+ if (!fm) return { skill: null, notice: declared ? `技能缺少有效 YAML frontmatter,已跳过:${filePath}` : null };
192
+ const description = typeof fm.description === "string" ? fm.description.trim().slice(0, 1024) : "";
193
+ if (!description) return { skill: null, notice: declared ? `技能缺少 description,已跳过:${filePath}` : null };
194
+ const parentDirName = path.basename(path.dirname(filePath));
195
+ const name = typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : parentDirName;
196
+ const disable = fm["disable-model-invocation"] === true || fm.disableModelInvocation === true;
197
+ return {
198
+ skill: {
199
+ name,
200
+ description,
201
+ filePath,
202
+ baseDir: path.dirname(filePath),
203
+ disableModelInvocation: disable,
204
+ _bytes: Buffer.byteLength(JSON.stringify({ name, description, filePath, baseDir: path.dirname(filePath) }), "utf8"),
205
+ },
206
+ notice: null,
207
+ };
208
+ }
209
+
210
+ function markSkillsCapped(acc, detail) {
211
+ if (acc.capped) return;
212
+ acc.capped = true;
213
+ acc.stopped = true;
214
+ acc.notices.push(detail || `技能超过 ${MAX_SKILLS} 个上限,已停止发现`);
215
+ }
216
+
217
+ async function ensureInside(abs, authRoot, notices, label) {
218
+ let real;
219
+ try { real = await realOf(abs); } catch (e) {
220
+ notices.push(`无法解析${label}:${abs}(${e?.code ?? e?.message ?? e})`);
221
+ return null;
222
+ }
223
+ if (!inside(real, authRoot)) {
224
+ notices.push(`目录外路径已拒绝:${label}(授权目录是 ${authRoot})`);
225
+ return null;
226
+ }
227
+ return real;
228
+ }
229
+
230
+ async function loadSkillsFromDirInternal(dir, includeRootFiles, ig, root, acc, authRoot) {
231
+ if (!existsSync(dir)) return;
232
+ if (acc.skills.length >= MAX_SKILLS) { markSkillsCapped(acc); return; }
233
+ // 循环 symlink 指向已扫过的目录时跳过,不能卡住运行器事件循环。
234
+ if (acc.visited.has(dir)) return;
235
+ acc.visited.add(dir);
236
+ addIgnoreRules(ig, dir, root);
237
+ let entries;
238
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
239
+
240
+ // SKILL.md 优先:有则加载并停止向子目录递归(pi 行为)
241
+ for (const entry of entries) {
242
+ if (entry.name !== "SKILL.md") continue;
243
+ const fullPath = path.join(dir, entry.name);
244
+ let isFile = entry.isFile();
245
+ if (entry.isSymbolicLink()) {
246
+ try { isFile = statSync(fullPath).isFile(); } catch { continue; }
247
+ }
248
+ const relPath = toPosix(path.relative(root, fullPath));
249
+ if (!isFile || ig.ignores(relPath, false)) continue;
250
+ const realFile = await ensureInside(fullPath, authRoot, acc.notices, relPath || entry.name);
251
+ if (!realFile) return; // 越界 symlink:记 notice,本目录当作技能根已处理完
252
+ const { skill, notice } = skillFromFile(realFile);
253
+ if (notice) acc.notices.push(notice);
254
+ if (skill) {
255
+ if (acc.total + skill._bytes > MAX_TOTAL_BYTES) {
256
+ acc.notices.push(`技能总大小将超过 ${MAX_TOTAL_BYTES} 字节,已停止发现:${fullPath}`);
257
+ acc.stopped = true;
258
+ return;
259
+ }
260
+ if (acc.skills.length >= MAX_SKILLS) { markSkillsCapped(acc); return; }
261
+ if (acc.names.has(skill.name)) acc.notices.push(`技能重名,已跳过后一份:${skill.name}(${skill.filePath})`);
262
+ if (!acc.names.has(skill.name)) {
263
+ acc.names.add(skill.name);
264
+ acc.total += skill._bytes;
265
+ const { _bytes, ...rest } = skill;
266
+ acc.skills.push(rest);
267
+ }
268
+ }
269
+ return; // 有 SKILL.md 不再递归
270
+ }
271
+
272
+ for (const entry of entries) {
273
+ if (acc.stopped || acc.skills.length >= MAX_SKILLS) return;
274
+ // 技能树内部仍跳过隐藏项;根上的 .pi / .agents / .claude 由 SKILL_DIRS 显式进入,不会走到这里
275
+ if (entry.name.startsWith(".")) continue;
276
+ if (entry.name === "node_modules") continue;
277
+ const fullPath = path.join(dir, entry.name);
278
+ let isDirectory = entry.isDirectory();
279
+ let isFile = entry.isFile();
280
+ if (entry.isSymbolicLink()) {
281
+ try {
282
+ const st = statSync(fullPath);
283
+ isDirectory = st.isDirectory();
284
+ isFile = st.isFile();
285
+ } catch { continue; }
286
+ }
287
+ const relPath = toPosix(path.relative(root, fullPath));
288
+ if (ig.ignores(relPath, isDirectory)) continue;
289
+ if (isDirectory) {
290
+ const realDir = await ensureInside(fullPath, authRoot, acc.notices, relPath || entry.name);
291
+ if (!realDir) continue; // 越界目录 symlink:跳过并已记 notice
292
+ await loadSkillsFromDirInternal(realDir, false, ig, root, acc, authRoot);
293
+ continue;
294
+ }
295
+ if (!isFile || !includeRootFiles || !entry.name.endsWith(".md")) continue;
296
+ const realFile = await ensureInside(fullPath, authRoot, acc.notices, relPath || entry.name);
297
+ if (!realFile) continue;
298
+ const { skill, notice } = skillFromFile(realFile);
299
+ if (notice) acc.notices.push(notice);
300
+ if (!skill) continue;
301
+ if (acc.total + skill._bytes > MAX_TOTAL_BYTES) {
302
+ acc.notices.push(`技能总大小将超过 ${MAX_TOTAL_BYTES} 字节,已停止发现:${fullPath}`);
303
+ acc.stopped = true;
304
+ return;
305
+ }
306
+ if (acc.skills.length >= MAX_SKILLS) { markSkillsCapped(acc); return; }
307
+ if (acc.names.has(skill.name)) acc.notices.push(`技能重名,已跳过后一份:${skill.name}(${skill.filePath})`);
308
+ if (!acc.names.has(skill.name)) {
309
+ acc.names.add(skill.name);
310
+ acc.total += skill._bytes;
311
+ const { _bytes, ...rest } = skill;
312
+ acc.skills.push(rest);
313
+ }
314
+ }
315
+ }
316
+
317
+ async function resolveRootFile(root, name) {
318
+ const abs = path.join(root, name);
319
+ if (!existsSync(abs)) return null;
320
+ let st;
321
+ try { st = statSync(abs); } catch { return null; }
322
+ if (!st.isFile()) return null;
323
+ let real;
324
+ try { real = await realOf(abs); } catch (e) {
325
+ return { error: `无法解析路径:${name}(${e?.code ?? e?.message ?? e})` };
326
+ }
327
+ if (!inside(real, root)) return { error: `目录外路径已拒绝:${name}(授权目录是 ${root})` };
328
+ return { real, size: st.size };
329
+ }
330
+
331
+ /**
332
+ * 在授权根目录发现项目上下文。无路径入参;越界 symlink 拒绝。
333
+ * @returns {{ kind: 'project_context', contextFiles: {path,content}[], skills: object[], notices: string[] }}
334
+ */
335
+ // @意图#R747(2026-11-06): 授权根增加 .claude/skills,兼容 Claude Code 项目技能目录
336
+ // @意图#R745(2026-11-06): 从客户已授权目录加载项目规则并按需提供技能
337
+ export async function discoverProjectContext(root) {
338
+ const notices = [];
339
+ const contextFiles = [];
340
+ let total = 0;
341
+
342
+ for (const name of AGENTS_CANDIDATES) {
343
+ const got = await resolveRootFile(root, name);
344
+ if (!got) continue;
345
+ if (got.error) { notices.push(got.error); break; }
346
+ if (got.size > MAX_FILE_BYTES) {
347
+ notices.push(`${name} 超过 ${MAX_FILE_BYTES} 字节,未注入项目规则(避免撑爆提示)`);
348
+ break;
349
+ }
350
+ if (total + got.size > MAX_TOTAL_BYTES) {
351
+ notices.push(`${name} 会使总大小超过 ${MAX_TOTAL_BYTES} 字节,未注入项目规则`);
352
+ break;
353
+ }
354
+ let raw;
355
+ try { raw = await fs.readFile(got.real, "utf8"); } catch (e) {
356
+ notices.push(`无法读取 ${name}:${e?.message ?? e}`);
357
+ break;
358
+ }
359
+ const content = stripBom(raw);
360
+ const bytes = Buffer.byteLength(content, "utf8");
361
+ if (bytes > MAX_FILE_BYTES) {
362
+ notices.push(`${name} 超过 ${MAX_FILE_BYTES} 字节,未注入项目规则(避免撑爆提示)`);
363
+ break;
364
+ }
365
+ contextFiles.push({ path: got.real, content });
366
+ total += bytes;
367
+ break; // 只取最高优先级一份
368
+ }
369
+
370
+ const acc = { skills: [], notices, names: new Set(), visited: new Set(), total, stopped: false, capped: false };
371
+ for (const rel of SKILL_DIRS) {
372
+ if (acc.stopped) break;
373
+ const dir = path.join(root, rel);
374
+ if (!existsSync(dir)) continue;
375
+ let realDir;
376
+ try { realDir = await realOf(dir); } catch (e) {
377
+ notices.push(`无法解析技能目录 ${rel}:${e?.code ?? e?.message ?? e}`);
378
+ continue;
379
+ }
380
+ if (!inside(realDir, root)) {
381
+ notices.push(`目录外路径已拒绝:${rel}(授权目录是 ${root})`);
382
+ continue;
383
+ }
384
+ const ig = createIgnoreMatcher();
385
+ // authRoot 用授权根(非 skills 子根),越界 symlink 相对整棵授权树判断
386
+ await loadSkillsFromDirInternal(realDir, true, ig, realDir, acc, root);
387
+ }
388
+
389
+ return {
390
+ kind: "project_context",
391
+ contextFiles,
392
+ skills: acc.skills,
393
+ notices,
394
+ };
395
+ }
396
+
397
+ /** ops 入口:忽略任何路径参数,只扫授权根。 */
398
+ // @意图#R747(2026-11-06): 授权根增加 .claude/skills,兼容 Claude Code 项目技能目录
399
+ // @意图#R745(2026-11-06): 从客户已授权目录加载项目规则并按需提供技能
400
+ export async function opContextSnapshot(_args, root) {
401
+ return discoverProjectContext(root);
402
+ }