mocode-ai 1.2.2 → 1.2.4

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.
@@ -337,9 +337,20 @@ export async function runAgentCore(opts) {
337
337
  const modelStartedAt = Date.now();
338
338
  const provider = safeProviderId(requestBaseURL);
339
339
  emitTrace('model_start', { model: requestModel, provider });
340
- const dynamicSystemSuffix = historyRebuilt
341
- ? '## Post-compaction recovery\nContext was compacted before this request. Re-establish the current objective and unresolved work from retained evidence or the session note, avoid repeating completed investigation, and re-read exact file context before any dependent edit.'
342
- : '';
340
+ // 动态注入(仅追加到 system 末尾,不触碰 staticBody 前缀 → 不破坏 prompt 缓存):
341
+ // - 开场分析:仅主线(step===0 !suppressOpeningAnalysis)注入——即"用户发一个任务后,
342
+ // agent 第一次模型调用"。子代理经 spawn.ts 传 suppressOpeningAnalysis:true 排除。
343
+ // - historyRebuilt:compact 恢复步;要求重新锚定目标。两者可叠加但互不串扰。
344
+ // .filter(Boolean) 保证空段不产生多余空行;后续 step 均为空串 → system 前缀稳定命中缓存。
345
+ // 安全保证:此段只拼进 requestHistory(新建对象),绝不回写 history[0],故不会跨 step/跨 turn 残留。
346
+ const dynamicSystemSuffix = [
347
+ (!opts.suppressOpeningAnalysis && step === 0)
348
+ ? '## Opening analysis\nBegin your FIRST response of this turn with a brief analysis of the request and your planned approach (1-3 sentences, no filler), THEN start tool calls. This opening is the only place where pre-tool prose is expected; after it, work quietly with no narration between tool calls.'
349
+ : '',
350
+ historyRebuilt
351
+ ? '## Post-compaction recovery\nContext was compacted before this request. Re-establish the current objective and unresolved work from retained evidence or the session note, avoid repeating completed investigation, and re-read exact file context before any dependent edit.'
352
+ : '',
353
+ ].filter(Boolean).join('\n\n');
343
354
  const systemMessage = history[0];
344
355
  const requestHistory = dynamicSystemSuffix
345
356
  && systemMessage?.role === 'system'
@@ -118,8 +118,9 @@ function writeToolHeader(tc) {
118
118
  }
119
119
  return;
120
120
  }
121
- // sub-agent 组还在跑但普通/mutation 工具先来了:收口 sub-agent 组,并补一条分隔空行。
122
- if (subAgentGroupId) {
121
+ // sub-agent 组还在跑,或组刚收口但尚未写入分隔空行时,
122
+ // 普通/mutation 工具先来了:先 flush 补一条分隔空行,避免摘要行粘在一起。
123
+ if (subAgentGroupId || subAgentGroupPendingSeparator) {
123
124
  flushToolBatch();
124
125
  }
125
126
  if (isMutationTool(tc.name)) {
@@ -74,6 +74,8 @@ export async function spawnAgent(opts) {
74
74
  const requested = opts.tools?.length ? new Set(opts.tools) : null;
75
75
  const readOnly = new Set(['read_file', 'glob', 'grep', 'web_search', 'web_fetch', 'use_skill', 'memory_search', 'memory_list']);
76
76
  toolsOverride = chatTools.filter((tool) => tool.function.name !== 'sub-agent' &&
77
+ // run_skill 会再次 spawn,禁止递归(避免 fork skill 里又 run_skill 套娃)。
78
+ tool.function.name !== 'run_skill' &&
77
79
  // plan_update 直写主会话 notes.md(不走 overlay),子代理不应改动主计划——统一排除。
78
80
  tool.function.name !== 'plan_update' &&
79
81
  (!requested || requested.has(tool.function.name)) &&
@@ -93,7 +95,11 @@ export async function spawnAgent(opts) {
93
95
  // 主屏实时渲染(子 agent 透明化):TUI 激活时把子 agent 内部步骤实时写入主内容区,
94
96
  // 复用 batch 折叠机制(mouse 点击摘要行可展开/收起)。TUI 未激活(host/非 TTY)时
95
97
  // 保持纯静默——只缓冲 transcript,不写屏,兼容嵌入宿主。
96
- const live = isTuiActive();
98
+ //
99
+ // quiet 模式(供 run_skill 等 opaque workflow 用):不产可展开 batch,
100
+ // 只在首次工具调用时写一行「◐ 执行 skill…」,完成后替换为「● skill 完成: 摘要」。
101
+ const live = isTuiActive() && !opts.quiet;
102
+ const quiet = isTuiActive() && !!opts.quiet;
97
103
  let liveBatchId = null;
98
104
  const liveLayout = () => ({
99
105
  contentWrite: (s) => layout.contentWrite(s),
@@ -106,6 +112,16 @@ export async function spawnAgent(opts) {
106
112
  });
107
113
  /** 本批是否挂在主侧调用行下(挂上了就由主侧负责分隔空行,自己不能往 buffer 末尾追加)。 */
108
114
  let nested = false;
115
+ // quiet 模式:完全静默,不写任何行(连 spinner 都没有);
116
+ // 子 agent 的执行过程与结果只回灌为主 agent 的 run_skill 工具结果。
117
+ const ensureQuietLine = (_label) => {
118
+ if (!quiet)
119
+ return; // 静默:什么都不输出
120
+ };
121
+ const replaceQuietLine = (_status, _detail) => {
122
+ if (!quiet)
123
+ return; // 静默:什么都不输出
124
+ };
109
125
  const ensureLiveBatch = () => {
110
126
  if (!live || liveBatchId)
111
127
  return;
@@ -163,6 +179,7 @@ export async function spawnAgent(opts) {
163
179
  const summary = summarizeToolCall(tc.name, tc.arguments);
164
180
  writeBuf(` ● ${tc.name} ${summary}\n`);
165
181
  // 实时写入主内容区:子 agent 的每次工具调用累计到摘要行计数。
182
+ ensureQuietLine(opts.quietLabel ?? t('skill.executing', { name: opts.prompt.slice(0, 40) }));
166
183
  ensureLiveBatch();
167
184
  if (liveBatchId) {
168
185
  batch.recordCall(liveBatchId, tc.name, summary);
@@ -198,6 +215,8 @@ export async function spawnAgent(opts) {
198
215
  onMaxSteps: () => {
199
216
  writeBuf(` ● 达到最大步数(${maxSteps}),子 agent 停止。\n`);
200
217
  finishLiveBatch('failed');
218
+ if (quiet)
219
+ replaceQuietLine('failed', t('skill.maxSteps', { max: String(maxSteps) }));
201
220
  },
202
221
  onDone: (elapsedMs, usage) => {
203
222
  const tok = usage && usage.totalTokens
@@ -205,10 +224,15 @@ export async function spawnAgent(opts) {
205
224
  : '';
206
225
  writeBuf(` ✻ 子 agent 耗时 ${(elapsedMs / 1000).toFixed(1)}s${tok}\n`);
207
226
  finishLiveBatch('complete');
227
+ if (quiet) {
228
+ replaceQuietLine('complete', `${t('skill.complete')} ${(elapsedMs / 1000).toFixed(1)}s${tok}`);
229
+ }
208
230
  },
209
231
  onAbort: () => {
210
232
  // 中断:子 agent 未完成,收尾批(不折叠——用户可能想看中断前做了什么)。
211
233
  finishLiveBatch('aborted');
234
+ if (quiet)
235
+ replaceQuietLine('aborted', t('skill.aborted'));
212
236
  },
213
237
  // onStepStart / onChatDone / onToolStart / onToolDone:子 agent 静默,无需 spinner 渲染。
214
238
  // abort 还原(history 还原 + 模式还原)由 core 的 abortRestore 处理,hooks 只管展示。
@@ -225,6 +249,7 @@ export async function spawnAgent(opts) {
225
249
  maxSteps,
226
250
  toolsOverride,
227
251
  contextState: localContextState,
252
+ suppressOpeningAnalysis: true, // 子代理不注入「开场分析」:仅主线面对用户的首次响应用
228
253
  onToolOutcome: (tool, args) => {
229
254
  if (tool === 'read_file' && typeof args.path === 'string')
230
255
  readSet.add(args.path);
@@ -37,7 +37,7 @@ function tryParseArgs(raw) {
37
37
  * 仅作 encoder 软目标;最终裁剪仍由末尾 capToolResultForHistory 兜底,故两处常量偶有漂移不致命。
38
38
  */
39
39
  function budgetFor(name) {
40
- if (name === 'use_skill')
40
+ if (name === 'use_skill' || name === 'run_skill')
41
41
  return MAX_SKILL_RESULT;
42
42
  if (name === 'memory_search')
43
43
  return MAX_MEMORY_RESULT;
@@ -217,6 +217,10 @@ const zhCN = {
217
217
  'subagent.stateOff': '关闭',
218
218
  'subagent.changedOn': '已开启子 Agent;sub-agent 将从下一次模型请求起可用。',
219
219
  'subagent.changedOff': '已关闭子 Agent;sub-agent 已从模型工具表移除。',
220
+ 'skill.executing': '执行 skill: {name}…',
221
+ 'skill.complete': 'Skill 执行完成',
222
+ 'skill.aborted': 'Skill 执行已中断',
223
+ 'skill.maxSteps': 'Skill 达到最大步数({max})',
220
224
  'subagent.usage': '用法:/subagent on|off|status',
221
225
  'fe.status': '前端工具簇:{state}',
222
226
  'fe.stateOn': '开启',
@@ -472,6 +476,10 @@ const en = {
472
476
  'subagent.stateOff': 'disabled',
473
477
  'subagent.changedOn': 'Sub-agents enabled; sub-agent will be available from the next model request.',
474
478
  'subagent.changedOff': 'Sub-agents disabled; sub-agent has been removed from the model tool list.',
479
+ 'skill.executing': 'Executing skill: {name}…',
480
+ 'skill.complete': 'Skill complete',
481
+ 'skill.aborted': 'Skill aborted',
482
+ 'skill.maxSteps': 'Skill reached max steps ({max})',
475
483
  'subagent.usage': 'Usage: /subagent on|off|status',
476
484
  'fe.status': 'Frontend tools: {state}',
477
485
  'fe.stateOn': 'enabled',
@@ -26,7 +26,10 @@ import { computePruneStats } from '../context/relevance.js';
26
26
  import { formatArtifactTokenSources } from '../context/artifacts.js';
27
27
  import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, appendCurrentSessionRuntimeEvent, hashTraceValue, } from '../session/index.js';
28
28
  import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, getCurrentTurnId, } from '../rollback/index.js';
29
- import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
29
+ import { listSkills, effectiveSystemPrompt, findSkill, } from '../skills/index.js';
30
+ import { clearSkillActivation } from '../skills/activation.js';
31
+ import { runSkill, renderSkillBody } from '../skills/runner.js';
32
+ import { isSkillTrusted } from '../skills/trust.js';
30
33
  import { buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, loadAll, } from '../memory/index.js';
31
34
  import fs from 'node:fs';
32
35
  import path from 'node:path';
@@ -50,6 +53,7 @@ function buildSlashCommands() {
50
53
  { name: '/clear', desc: d('commands.clear') },
51
54
  { name: '/context', desc: d('commands.context') },
52
55
  { name: '/skills', desc: d('commands.skills') },
56
+ { name: '/skill', desc: '执行某个 skill(/skill <name> [args-json])' },
53
57
  { name: '/compact', desc: d('commands.compact') },
54
58
  { name: '/resume', desc: d('commands.sessionResume') },
55
59
  { name: '/sessions', desc: d('commands.sessionBrowse') },
@@ -998,6 +1002,8 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
998
1002
  // 入口设定本轮初始模式(合成执行轮传 false→auto;用户轮传当前 mode)。
999
1003
  // setAgentMode 触发 listener 重写 history[0];LLM 可在轮中调 switch_mode 切模式,runAgent 每步读实时值。
1000
1004
  setAgentMode(planMode ? 'plan' : 'auto');
1005
+ // 新用户轮开始:清除上一轮 inline skill 的激活态(允许/disallowed 约束一 turn 有效)。
1006
+ clearSkillActivation();
1001
1007
  // 运行中每步 chat() 返回后刷新状态行 context 用量条(用 fresh lastUsage / 估算),
1002
1008
  // 否则整轮冻结在轮首 refreshStatusBase 的值,「执行 grep」时 2k/1000k 不动。
1003
1009
  const result = await runAgent(history, userInput, signal, () => {
@@ -1455,9 +1461,69 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1455
1461
  else {
1456
1462
  layout.contentWrite(`${ui.dim}已发现 ${skills.length} 个 skill:${ui.reset}\n`);
1457
1463
  for (const s of skills) {
1458
- layout.contentWrite(` ${ui.accent}${s.name}${ui.reset} ${ui.dim}${s.description}${ui.reset}\n`);
1464
+ const badges = [];
1465
+ if (s.context === 'fork')
1466
+ badges.push('fork');
1467
+ if (!s.modelInvocable)
1468
+ badges.push('manual-only');
1469
+ badges.push(s.origin);
1470
+ if (s.origin === 'project') {
1471
+ badges.push(isSkillTrusted(s) ? 'trusted' : 'untrusted');
1472
+ }
1473
+ const badgeStr = badges.length ? ` ${ui.dim}[${badges.join('|')}]${ui.reset}` : '';
1474
+ layout.contentWrite(` ${ui.accent}${s.name}${ui.reset}${badgeStr} ${ui.dim}${s.description}${ui.reset}\n`);
1475
+ if (s.allowedTools?.length) {
1476
+ layout.contentWrite(` ${ui.dim}allowed: ${s.allowedTools.join(', ')}${ui.reset}\n`);
1477
+ }
1478
+ if (s.disallowedTools?.length) {
1479
+ layout.contentWrite(` ${ui.dim}disallowed: ${s.disallowedTools.join(', ')}${ui.reset}\n`);
1480
+ }
1481
+ if (s.warnings.length) {
1482
+ layout.contentWrite(` ${ui.dim}warnings: ${s.warnings.join('; ')}${ui.reset}\n`);
1483
+ }
1484
+ }
1485
+ layout.contentWrite(`${ui.dim}(用 use_skill 加载指令; fork 类用 run_skill 执行; 也支持 /skill <name> [args-json])${ui.reset}\n`);
1486
+ }
1487
+ continue;
1488
+ }
1489
+ // /skill <name> [args-json]:直接执行一个 skill(fork 走 run_skill,inline 打印渲染后正文)。
1490
+ if (line === '/skill' || line.startsWith('/skill ')) {
1491
+ const rest = line.slice('/skill'.length).trim();
1492
+ const sp = rest.indexOf(' ');
1493
+ const name = sp === -1 ? rest : rest.slice(0, sp);
1494
+ const argStr = sp === -1 ? '' : rest.slice(sp + 1).trim();
1495
+ if (!name) {
1496
+ layout.contentWrite(`${ui.dim}用法: /skill <name> [args-json]${ui.reset}\n`);
1497
+ continue;
1498
+ }
1499
+ let args;
1500
+ if (argStr) {
1501
+ try {
1502
+ args = JSON.parse(argStr);
1503
+ }
1504
+ catch {
1505
+ layout.contentWrite(`${ui.dim}args 不是合法 JSON,已忽略。${ui.reset}\n`);
1506
+ }
1507
+ }
1508
+ const skill = findSkill(name);
1509
+ if (!skill) {
1510
+ layout.contentWrite(`错误:未找到 skill "${name}"。\n`);
1511
+ continue;
1512
+ }
1513
+ if (skill.context === 'fork') {
1514
+ layout.contentWrite(`${ui.dim}执行 fork skill "${name}"…${ui.reset}\n`);
1515
+ const out = await runSkill({ name, args });
1516
+ layout.contentWrite((out.status === 'success' ? '' : `[${out.status}] `) + out.output + '\n');
1517
+ }
1518
+ else {
1519
+ const body = await renderSkillBody(skill, args);
1520
+ if (body === null)
1521
+ layout.contentWrite(`错误:未找到 skill "${name}" 的正文。\n`);
1522
+ else {
1523
+ // 仅预览:inline 正文未进入模型 history,不激活工具面约束;
1524
+ // 要让模型按此 skill 工作,请让它调 use_skill(name) 或在下条消息里提及该 skill。
1525
+ layout.contentWrite(`# Skill: ${name}(预览,模型尚未看到此内容)\n\n${body}\n`);
1459
1526
  }
1460
- layout.contentWrite(`${ui.dim}(用 use_skill 工具加载某 skill 的完整指令)${ui.reset}\n`);
1461
1527
  }
1462
1528
  continue;
1463
1529
  }
@@ -0,0 +1,26 @@
1
+ // inline skill 激活态(设计 §3.6):use_skill 成功加载 inline skill 时置位,
2
+ // 该 turn 内模型的 disallowed-tools 约束生效(constants.getRuntimeDisabledTools 消费)。
3
+ // 一 turn 语义:下一次用户轮开始时由 repl 调 clearSkillActivation() 清除。
4
+ //
5
+ // 刻意不设 allowed 授权:inline 加载不过信任门禁,自动放行 confirm 级工具会让
6
+ // 未信任的项目 skill 绕过权限确认。allowed-tools 只在 fork 模式下经 spawnAgent
7
+ // 的工具白名单生效(runner.runSkill)。
8
+ //
9
+ // 叶子模块:仅依赖 toolmap(亦叶子),不引 tools/permissions/agent,避免环。
10
+ import { mapSkillToolName } from './toolmap.js';
11
+ let active = null;
12
+ /** use_skill 成功加载 inline skill 时调用。 */
13
+ export function activateSkill(skill) {
14
+ const disallowed = skill.disallowedTools?.map(mapSkillToolName).filter((t) => t != null);
15
+ active = {
16
+ name: skill.name,
17
+ disallowed: disallowed && disallowed.length ? new Set(disallowed) : null,
18
+ };
19
+ }
20
+ /** 用户新轮开始时清激活态。 */
21
+ export function clearSkillActivation() {
22
+ active = null;
23
+ }
24
+ export function getActiveSkill() {
25
+ return active;
26
+ }
@@ -50,6 +50,11 @@ export const builtinSkills = [
50
50
  body: CODEGRAPH_BODY,
51
51
  dir: 'builtin',
52
52
  skillMdPath: 'builtin',
53
+ // 内置 skill:恒信任、内联、模型可自动触发。
54
+ context: 'inline',
55
+ modelInvocable: true,
56
+ origin: 'builtin',
57
+ warnings: [],
53
58
  },
54
59
  ];
55
60
  /** 内置 skill 名字集,供 discoverSkills 跳过/覆盖时使用。 */
@@ -1,10 +1,11 @@
1
1
  // skills 发现子系统。
2
2
  // 仅依赖 node 标准库,是叶子模块:不依赖 config/agent/llm/tools,避免环。
3
3
  //
4
- // 约定:每个 skill 是一个目录 <skill-name>/SKILL.md,顶部 YAML frontmatter
5
- // (name / description 必需,version / license 可选)。元数据始终注入系统提示,
6
- // 正文由 use_skill 工具按需加载(渐进式披露)
7
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
4
+ // 约定:每个 skill 是一个目录 <skill-name>/SKILL.md,顶部 YAML frontmatter
5
+ // 元数据始终注入系统提示,正文由 use_skill 工具按需加载(渐进式披露)
6
+ // frontmatter 兼容 Agent Skills 开放标准(name/description/allowed-tools/context/agent…),
7
+ // 解析器是受限 YAML 子集(自写,不引 yaml 依赖),越界字段一律忽略 + 记 warning。
8
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
8
9
  import os from 'node:os';
9
10
  import path from 'node:path';
10
11
  import { builtinSkillNames } from './builtin-skills.js';
@@ -39,10 +40,85 @@ export function resolveSkillsDirs() {
39
40
  path.join(process.cwd(), '.mocode', 'skills'),
40
41
  ];
41
42
  }
43
+ /** 数组型 frontmatter 键(这些键的整行值按空白切分为 token 列表)。 */
44
+ const ARRAY_KEYS = new Set(['allowed-tools', 'disallowed-tools']);
45
+ /** 行内数组 / 方括号数组:按空白或逗号切分,去空。 */
46
+ function splitList(s) {
47
+ return s
48
+ .split(/[\s,]+/)
49
+ .map((x) => x.trim())
50
+ .filter(Boolean);
51
+ }
52
+ /** 取标量值(数组值返回 undefined)。 */
53
+ function scalar(meta, key) {
54
+ const v = meta[key];
55
+ return typeof v === 'string' ? v : undefined;
56
+ }
57
+ /** 取可能为数组的值,统一成 string[] | undefined。 */
58
+ function asArray(meta, key) {
59
+ const v = meta[key];
60
+ if (Array.isArray(v))
61
+ return v.length ? v : undefined;
62
+ if (typeof v === 'string' && v)
63
+ return splitList(v);
64
+ return undefined;
65
+ }
66
+ function truthy(meta, key) {
67
+ const v = scalar(meta, key);
68
+ return v != null && (v.toLowerCase() === 'true' || v === '1');
69
+ }
70
+ function num(meta, key) {
71
+ const v = scalar(meta, key);
72
+ if (v == null)
73
+ return undefined;
74
+ const n = Number(v);
75
+ return Number.isFinite(n) ? n : undefined;
76
+ }
77
+ /** 去掉首尾配对引号(YAML 标量基本语义;`"use when: foo"` → `use when: foo`)。 */
78
+ function unquote(s) {
79
+ if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[s.length - 1] === s[0]) {
80
+ return s.slice(1, -1);
81
+ }
82
+ return s;
83
+ }
84
+ /** `context: fork` 或兼容别名 `mode: fork` → 'fork';其余 'inline'。 */
85
+ function resolveContext(meta) {
86
+ if (scalar(meta, 'context') === 'fork')
87
+ return 'fork';
88
+ if (scalar(meta, 'mode') === 'fork')
89
+ return 'fork'; // 旧内部别名
90
+ return 'inline';
91
+ }
92
+ /** `agent:` 映射到 mocode 的 read/write 双态;未知值记 warning 并保守按 read。 */
93
+ function resolveAgentMode(meta, warnings) {
94
+ const a = scalar(meta, 'agent');
95
+ if (!a)
96
+ return undefined;
97
+ const low = a.toLowerCase();
98
+ if (['explore', 'plan', 'research', 'read'].includes(low))
99
+ return 'read';
100
+ if (['general-purpose', 'write'].includes(low))
101
+ return 'write';
102
+ warnings.push(`未知 agent 值 "${a}",按 read(只读,保守)处理`);
103
+ return 'read';
104
+ }
105
+ /** 收集本轮不支持、已忽略的字段,用于 /skills 提示(不阻断加载)。 */
106
+ function collectWarnings(meta) {
107
+ const warnings = [];
108
+ for (const k of ['hooks', 'model', 'effort', 'paths']) {
109
+ if (k in meta)
110
+ warnings.push(`字段 "${k}" 当前不支持,已忽略`);
111
+ }
112
+ return warnings;
113
+ }
42
114
  /**
43
- * 极简 YAML frontmatter 解析(不引入 yaml 依赖)。
44
- * 仅支持单行键值:按首个冒号切 key/value,trim,去首尾配对引号。
45
- * 不支持块标量(| / >)与多行值——v1 限制(skill 的 description 实际多为单行)。
115
+ * 受限 YAML 子集解析(不引入 yaml 依赖)。
116
+ * 支持四种值形态:
117
+ * - 标量 `key: value`
118
+ * - 行内数组 `allowed-tools: a, b` / 方括号 `allowed-tools: [a, b]`
119
+ * - 块序列(下一行起 ` - item`)
120
+ * - 空格分隔字符串(标准形态 `allowed-tools: Read grep run_command`,仅数组型键)
121
+ * 其余(嵌套 map / 块标量)一律忽略,保持攻击面与维护成本可控。
46
122
  *
47
123
  * 返回 { meta, body }:无 frontmatter 时 meta 为空、body 为原文。
48
124
  */
@@ -70,7 +146,16 @@ export function parseFrontmatter(content) {
70
146
  if (!closed)
71
147
  return { meta, body: content }; // 无闭合:视为无 frontmatter
72
148
  i++; // 跳过闭 ---
73
- for (const line of fmLines) {
149
+ const isListItem = (l) => {
150
+ const t = l.trim();
151
+ return t === '-' || t.startsWith('- ');
152
+ };
153
+ const itemText = (l) => {
154
+ const t = l.trim();
155
+ return t === '-' ? '' : t.slice(2).trim();
156
+ };
157
+ for (let k = 0; k < fmLines.length; k++) {
158
+ const line = fmLines[k];
74
159
  const trimmed = line.trim();
75
160
  if (trimmed === '' || trimmed.startsWith('#'))
76
161
  continue;
@@ -78,15 +163,30 @@ export function parseFrontmatter(content) {
78
163
  if (colon === -1)
79
164
  continue;
80
165
  const key = line.slice(0, colon).trim();
81
- let value = line.slice(colon + 1).trim();
82
- // 去首尾配对引号(" 或 ')
83
- if (value.length >= 2 &&
84
- ((value[0] === '"' && value[value.length - 1] === '"') ||
85
- (value[0] === "'" && value[value.length - 1] === "'"))) {
86
- value = value.slice(1, -1);
166
+ const value = line.slice(colon + 1).trim();
167
+ // 块序列:当前值为空且下一行是列表项
168
+ if (value === '' && k + 1 < fmLines.length && isListItem(fmLines[k + 1])) {
169
+ const arr = [];
170
+ let j = k + 1;
171
+ while (j < fmLines.length && isListItem(fmLines[j])) {
172
+ arr.push(itemText(fmLines[j]));
173
+ j++;
174
+ }
175
+ meta[key] = arr;
176
+ k = j - 1; // for 循环会再 +1
177
+ continue;
178
+ }
179
+ // 方括号数组
180
+ if (value.startsWith('[') && value.endsWith(']')) {
181
+ meta[key] = splitList(value.slice(1, -1));
182
+ continue;
183
+ }
184
+ // 数组型键:整行按空白切分为 token(标准形态 Bash(git:*) Read)
185
+ if (ARRAY_KEYS.has(key)) {
186
+ meta[key] = value ? splitList(value) : [];
187
+ continue;
87
188
  }
88
- if (key)
89
- meta[key] = value;
189
+ meta[key] = unquote(value);
90
190
  }
91
191
  const body = lines.slice(i).join('\n').replace(/^\n+/, '');
92
192
  return { meta, body };
@@ -99,6 +199,10 @@ export function parseFrontmatter(content) {
99
199
  */
100
200
  export function discoverSkills() {
101
201
  const dirs = resolveSkillsDirs();
202
+ // 记录本次发现的所有 SKILL.md 路径,供 skillsScanSignature 做廉价热重载探测
203
+ // (只 stat 已知文件,不每次全量重扫)。新增 skill 目录会改目录 mtime → 触发全量重扫。
204
+ const seenMdPaths = [];
205
+ const projDir = path.join(process.cwd(), '.mocode', 'skills');
102
206
  const byName = new Map();
103
207
  for (const dir of dirs) {
104
208
  let entries = [];
@@ -110,6 +214,8 @@ export function discoverSkills() {
110
214
  catch {
111
215
  continue; // 目录不存在或无权限,静默跳过
112
216
  }
217
+ // SKILLS_DIRS 自定义目录无法归类 project,统一按 user(免门禁)处理。
218
+ const origin = dir === projDir ? 'project' : 'user';
113
219
  for (const name of entries) {
114
220
  // 跳过内置 skill 同名目录(项目级用户版可放在 ~/.mocode/skills/<name>/
115
221
  // 覆盖内置版;cwd/.mocode/skills/<name>/ 也算项目级覆盖)。discoverSkills 按
@@ -122,19 +228,32 @@ export function discoverSkills() {
122
228
  try {
123
229
  if (!existsSync(skillMdPath))
124
230
  continue;
231
+ seenMdPaths.push(skillMdPath);
125
232
  const content = readFileSync(skillMdPath, 'utf8');
126
- const { meta } = parseFrontmatter(content);
127
- const skillName = (meta.name || name).trim();
128
- const description = (meta.description || '').trim();
233
+ const { meta, body } = parseFrontmatter(content);
234
+ const skillName = (scalar(meta, 'name') || name).trim();
235
+ const description = (scalar(meta, 'description') || '').trim();
129
236
  if (!description)
130
237
  continue; // 缺 description 跳过(它是最触发机制)
238
+ const warnings = collectWarnings(meta);
131
239
  byName.set(skillName, {
132
240
  name: skillName,
133
241
  description,
134
- version: meta.version?.trim() || undefined,
135
- license: meta.license?.trim() || undefined,
242
+ version: scalar(meta, 'version')?.trim() || undefined,
243
+ license: scalar(meta, 'license')?.trim() || undefined,
136
244
  dir: skillDir,
137
245
  skillMdPath,
246
+ // body 留空:用户/项目 skill 由 getSkillBody 走文件读取(保留热重载语义)。
247
+ context: resolveContext(meta),
248
+ agentMode: resolveAgentMode(meta, warnings),
249
+ allowedTools: asArray(meta, 'allowed-tools'),
250
+ disallowedTools: asArray(meta, 'disallowed-tools'),
251
+ modelInvocable: !truthy(meta, 'disable-model-invocation'),
252
+ maxSteps: num(meta, 'max-steps'),
253
+ argumentHint: scalar(meta, 'argument-hint')?.trim() || undefined,
254
+ whenToUse: scalar(meta, 'when_to_use')?.trim() || undefined,
255
+ origin,
256
+ warnings,
138
257
  });
139
258
  }
140
259
  catch {
@@ -142,5 +261,32 @@ export function discoverSkills() {
142
261
  }
143
262
  }
144
263
  }
264
+ // 首次全量扫描后,把发现的 SKILL.md 路径交给热重载探测器,后续只 stat 这些已知文件。
265
+ lastKnownMdPaths = seenMdPaths;
145
266
  return Array.from(byName.values());
146
267
  }
268
+ /** 上一次 discoverSkills 发现的 SKILL.md 路径;skillsScanSignature 复用,避免每次全量重扫。 */
269
+ let lastKnownMdPaths = [];
270
+ /** 供热重载签名计算:各 skills 目录 mtime + 已知 SKILL.md 的 mtime。
271
+ * 目录 mtime 变化(新增/删除 skill 目录)或任一已知文件变动 → 签名变更 → 重扫。
272
+ * 首轮 lastKnownMdPaths 为空时退化为只测目录 mtime(首轮 listSkills 后即填充)。 */
273
+ export function skillsScanSignature() {
274
+ const parts = [];
275
+ for (const dir of resolveSkillsDirs()) {
276
+ try {
277
+ parts.push('D' + dir + statSync(dir).mtimeMs);
278
+ }
279
+ catch {
280
+ parts.push('D' + dir + 'x');
281
+ }
282
+ }
283
+ for (const p of lastKnownMdPaths) {
284
+ try {
285
+ parts.push('F' + p + statSync(p).mtimeMs);
286
+ }
287
+ catch {
288
+ parts.push('F' + p + 'x');
289
+ }
290
+ }
291
+ return parts.join('\0');
292
+ }
@@ -5,13 +5,20 @@
5
5
  // 优先级:内置 skill < 用户/系统 skill < 项目级 skill(后注册覆盖先注册,
6
6
  // 项目级最后扫描,自然在 Map 中胜出)。同名用户 skill 完全替换内置版。
7
7
  import { existsSync, readFileSync } from 'node:fs';
8
- import { discoverSkills, parseFrontmatter } from './discover.js';
8
+ import { discoverSkills, parseFrontmatter, skillsScanSignature, } from './discover.js';
9
9
  import { builtinSkills } from './builtin-skills.js';
10
10
  let cache = null;
11
+ let cacheKey = null;
12
+ /** 当前扫描签名(各 skills 目录 + 各 SKILL.md 的 mtime 拼接);用于热重载失效判断。 */
13
+ function currentKey() {
14
+ return skillsScanSignature();
15
+ }
11
16
  /** 已发现的 skill 列表(懒加载,首次调用触发扫描;启动期 repl 调一次)。
12
- * 合并策略:内置 skill 作兜底,discoverSkills 的输出(按目录优先级后写覆盖)优先生效。 */
17
+ * 合并策略:内置 skill 作兜底,discoverSkills 的输出(按目录优先级后写覆盖)优先生效。
18
+ * 热重载:签名变更(任一 SKILL.md 或目录被增删/改动)即重扫,无需重启。 */
13
19
  export function listSkills() {
14
- if (cache !== null)
20
+ const key = currentKey();
21
+ if (cache !== null && cacheKey === key)
15
22
  return cache;
16
23
  const discovered = discoverSkills();
17
24
  const byName = new Map();
@@ -22,15 +29,25 @@ export function listSkills() {
22
29
  for (const skill of discovered)
23
30
  byName.set(skill.name, skill);
24
31
  cache = Array.from(byName.values());
32
+ cacheKey = key;
25
33
  return cache;
26
34
  }
35
+ /** 强制下次 listSkills 重扫(外部触发热重载用)。 */
36
+ export function invalidateSkillsCache() {
37
+ cache = null;
38
+ cacheKey = null;
39
+ }
40
+ /** 按名字取单个 skill(区分大小写;找不到返 null)。 */
41
+ export function findSkill(name) {
42
+ return listSkills().find((s) => s.name === name) ?? null;
43
+ }
27
44
  /**
28
45
  * 读取某 skill 的 SKILL.md 正文(去掉 frontmatter)。
29
46
  * 纯函数:找不到 / 读失败返 null(错误字符串交给调用方工具层生成)。
30
47
  * 内置 skill 直接返回 body 字段(无 fs 依赖)。
31
48
  */
32
49
  export function getSkillBody(name) {
33
- const skill = listSkills().find((s) => s.name === name);
50
+ const skill = findSkill(name);
34
51
  if (!skill)
35
52
  return null;
36
53
  if (skill.body) {
@@ -49,12 +66,20 @@ export function getSkillBody(name) {
49
66
  return null;
50
67
  }
51
68
  }
52
- /** 拼进系统提示的 skill 段;无 skill 返空串(零行为变化)。 */
69
+ /** 拼进系统提示的 skill 段;无 skill 返空串(零行为变化)。
70
+ * - disable-model-invocation: true 的 skill 不进列表(模型看不见,仅 /skill 可触发)。
71
+ * - context: fork 的 skill 追加 [fork] 徽标 + 一句调用指引,提示模型它可被「执行」而非「阅读」。 */
53
72
  export function buildSkillsSection() {
54
- const skills = listSkills();
73
+ const skills = listSkills().filter((s) => s.modelInvocable);
55
74
  if (skills.length === 0)
56
75
  return '';
57
- const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
76
+ const lines = skills.map((s) => {
77
+ const badge = s.context === 'fork' ? ' [fork]' : '';
78
+ const hint = s.context === 'fork'
79
+ ? ' (call run_skill to execute it as an isolated workflow)'
80
+ : ' (call use_skill to load its instructions)';
81
+ return `- ${s.name}${badge}: ${s.description}${hint}`;
82
+ });
58
83
  return [
59
84
  '',
60
85
  '',