Ver código fonte

fix(skill): enforce manual invocation policy

Tianyi Cui 1 mês atrás
pai
commit
ef7195a00a

+ 2 - 2
.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md
-2026-08-08-lightweight-routine-documentation-translation.md: 713c2f14541aff49411b6f7d8b6bf5b4e02fa667
-2026-08-08-lightweight-routine-documentation-translation.zh.md: 7cb13e9fcaa8b8a4d38ab6c0050eec99c1025b46
+2026-08-08-lightweight-routine-documentation-translation.md: ff4d6005588b562018bf1ee40d6dabb5569766f0
+2026-08-08-lightweight-routine-documentation-translation.zh.md: fe809d328e739c2c869d5597cf6786198864bd56

+ 3 - 3
.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md

@@ -10,8 +10,8 @@ Routine bilingual edits automatically selected the full [translation skill](../.
 
 ## Decision
 
-- **Routine translation is one shot and one pass.** The active agent loads [terminology.md](../../../../docs/i18n/terminology.md), translates only the changed content directly, preserves reviewed counterpart prose outside the change, and re-records the pair. It does not invoke a translation skill, generate a briefing, start a separate translation-review pass, or delegate translation to a subagent.
-- **The extended workflow is manual-only.** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) retains its briefing, delegated prose, whole-document, and scoped-verification paths. Claude Code sees `disable-model-invocation: true` with `user-invocable: true` in `SKILL.md`; Codex sees `policy.allow_implicit_invocation: false` in `agents/openai.yaml`. The repository's `.claude/skills` symlink projects the same skill directory to Claude Code, so both products share one committed workflow while enforcing their own invocation metadata.
+- **Routine translation is one shot and one pass.** The active agent loads [terminology.md](../../../../docs/i18n/terminology.md), translates only the changed content directly, moves a terminology annotation when the true first occurrence crosses the edit boundary, otherwise preserves reviewed counterpart prose outside the change, and re-records the pair. It does not invoke a translation skill, generate a briefing, start a separate translation-review pass, or delegate translation to a subagent.
+- **The extended workflow is manual-only.** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) retains its briefing, delegated prose, whole-document, and scoped-verification paths. The [Claude Code skill contract](https://code.claude.com/docs/en/skills#control-who-invokes-a-skill) reads `disable-model-invocation: true` with `user-invocable: true` in `SKILL.md`; Codex reads `policy.allow_implicit_invocation: false` in `agents/openai.yaml`. The repository's `.claude/skills` symlink projects the same skill directory to Claude Code, so both products share one committed workflow while enforcing their own invocation metadata. The `doc-sync` skill-invocation-metadata gate keeps those independent policies aligned.
 - **Automatic workflows do not chain into the manual skill.** Root and documentation instructions own the lightweight default. Documentation, website-sync, prose, and code-review skills link to those instructions or the i18n contracts instead of loading `dsh-translate-docs` from an inferred bilingual change.
 - **The pairing and review contracts stay intact.** Both language files still update together, untouched counterpart wording remains stable, terminology stays binding, the consistency record is rewritten only after the active agent confirms the pair, and `doc-sync` retains the corpus-wide mechanical checks. Human review still owns semantic translation quality.
 
@@ -27,4 +27,4 @@ Routine bilingual edits automatically selected the full [translation skill](../.
 - Ordinary development pays for the changed source text, its local counterpart context, and the terminology table rather than the extended workflow's briefing and subagent context.
 - The active agent owns the final routine translation in the same turn. The lightweight path deliberately gives up the extended workflow's generated alignment, delegated isolation, and separate prose-verification pass.
 - Explicit users can still invoke the full workflow through `/dsh-translate-docs` in Claude Code or `$dsh-translate-docs` in Codex.
-- The Claude Code frontmatter and Codex policy file are separate product contracts and must remain aligned when the skill's invocation policy changes.
+- The Claude Code frontmatter and Codex policy file are separate product contracts; `doc-sync` rejects a skill that becomes manual-only on only one product or becomes unavailable to the Claude Code user as well as the model.

+ 3 - 3
.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md

@@ -10,8 +10,8 @@ Status: implemented
 
 ## 决策
 
-- **日常翻译一次完成,只处理一遍。** 当前 agent(智能体)加载 [terminology.md](../../../../docs/i18n/terminology.md),直接翻译发生改动的内容,保留改动之外已经评审的对侧文件行文,并重新记录配对。它不会调用翻译 skill、生成简报、启动单独的翻译评审轮次,也不会把翻译委派给 subagent。
-- **扩展工作流仅限手动调用。** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 保留简报、行文翻译委派、整篇文档和按范围核验路径。在 `SKILL.md` 中,Claude Code 读取 `disable-model-invocation: true` 和 `user-invocable: true`;在 `agents/openai.yaml` 中,Codex 读取 `policy.allow_implicit_invocation: false`。仓库的 `.claude/skills` 符号链接把同一个 skill 目录映射给 Claude Code,因此两个产品共享同一份提交到仓库的工作流,同时分别执行各自的调用元数据契约。
+- **日常翻译一次完成,只处理一遍。** 当前 agent(智能体)加载 [terminology.md](../../../../docs/i18n/terminology.md),直接翻译发生改动的内容;如果术语的实际首现位置跨过了编辑边界则移动相应括注,否则保留改动之外已经评审的对侧文件行文;最后重新记录配对。它不会调用翻译 skill、生成简报、启动单独的翻译评审轮次,也不会把翻译委派给 subagent。
+- **扩展工作流仅限手动调用。** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 保留简报、行文翻译委派、整篇文档和按范围核验路径。[Claude Code skill 契约](https://code.claude.com/docs/en/skills#control-who-invokes-a-skill)读取 `SKILL.md` 中的 `disable-model-invocation: true` 和 `user-invocable: true`;Codex 读取 `agents/openai.yaml` 中的 `policy.allow_implicit_invocation: false`。仓库的 `.claude/skills` 符号链接把同一个 skill 目录映射给 Claude Code,因此两个产品共享同一份提交到仓库的工作流,同时分别执行各自的调用元数据契约。`doc-sync` 中的 skill 调用元数据门禁会让这两份独立策略保持一致。
 - **自动工作流不会串联调用这项仅限手动调用的 skill。** 轻量默认行为由根级指令和文档指令定义。文档、网站同步、行文和代码评审 skill 会链接这些指令或 i18n 契约,而不会因为推断到双语改动就加载 `dsh-translate-docs`。
 - **配对契约与评审契约保持不变。** 两种语言文件仍会一并更新;未触及的对侧文件措辞保持稳定;术语约束仍然有效;只有当前 agent 确认配对后,才会重写一致性记录;`doc-sync`(文档同步门禁)继续执行全语料机械检查。语义层面的翻译质量仍由人工评审负责。
 
@@ -27,4 +27,4 @@ Status: implemented
 - 普通开发的成本来自发生改动的源文本、其局部对侧文件上下文和术语表,不再来自扩展工作流的简报与 subagent 上下文。
 - 当前 agent 在同一轮次内对日常翻译的最终结果负责。轻量路径有意放弃扩展工作流提供的自动生成对齐信息、委派所提供的隔离,以及单独的行文核验轮次。
 - 用户仍可在 Claude Code 中通过 `/dsh-translate-docs`,或在 Codex 中通过 `$dsh-translate-docs` 显式调用完整工作流。
-- Claude Code frontmatter 与 Codex 策略文件是彼此独立的产品契约;skill 调用策略变更时,两者必须保持一致
+- Claude Code frontmatter 与 Codex 策略文件是彼此独立的产品契约;如果某项 skill 仅在一个产品中变为手动调用,或者在 Claude Code 中对模型和用户都不可用,`doc-sync` 会拒绝该状态

+ 1 - 0
.agents/skills/dsh-translate-docs/SKILL.md

@@ -49,6 +49,7 @@ When translations need to be written from scratch, the orchestrating agent does
 
 - **Pass 1 — write, don't transpose.** Read a semantic unit, then restate it as a native technical author in the nearest [style sample's](../../../docs/i18n/style-samples.md) register. Preserve the required frame without forcing sentence-by-sentence correspondence.
 - **Pass 2 — verify against the source, clause by clause.** Fidelity is checked here, not written in: confirm nothing was added or dropped, every term follows the table, and each code span survived verbatim. Fix by rewriting the sentence natively, not by patching words into it.
+- **Read the completed counterpart alone.** After the source comparison, read the translated file without the source beside it and rewrite phrasing whose awkwardness only becomes visible in isolation.
 - Write only the final text to the file, never drafts or notes.
 - Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified. For a Chinese target, use the Chinese and first-occurrence columns; an unlisted term needs a citable Chinese OSS/vendor precedent or stays English under 「待定术语」. For an English target, use the English column and an established English technical term; preserve an ambiguous source term with a short gloss and list it as pending. Never invent a rendering inline.
 - Code blocks are byte-identical across the pair, comments included. Relative links keep their `.md` targets; only the switcher line links `.zh.md`.

+ 1 - 1
docs/AGENTS.md

@@ -40,7 +40,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb
 - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit.
 - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)).
 - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)).
-- **Bilingual pairs update together**: load [terminology](i18n/terminology.md), translate changed content one-shot and one-pass in the active agent, preserve untouched counterpart prose, and re-record. Only explicit user invocation may run `dsh-translate-docs` ([contract](i18n/README.md)).
+- **Pairs update together**: [Terminology-guided](i18n/terminology.md), single-pass active-agent work repositions first-use annotations, preserves untouched prose, and re-records; `dsh-translate-docs` remains user-invoked ([contract](i18n/README.md)).
 - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, failure, timing, ownership, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details.
 - Write directly: name actors and facts ([decision](../.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). Reserve `seam` for the defined capability. Name the exact check, type, API, operation, or behavior instead of metaphorical "gate", "vocabulary", or "surface".
 

+ 1 - 0
package.json

@@ -77,6 +77,7 @@
     "verify-agent-note-format": "tsx scripts/verify-agent-note-format.ts",
     "verify-archived-agent-notes": "tsx scripts/verify-archived-agent-notes.ts",
     "verify-type-equiv": "tsx scripts/verify-type-equiv.ts",
+    "verify-skill-invocation-metadata": "tsx scripts/verify-skill-invocation-metadata.ts",
     "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts",
     "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts",
     "resolve-translation-pairing-conflicts": "tsx scripts/merge-translation-pairing.ts --resolve",

+ 1 - 0
scripts/run-gates.ts

@@ -599,6 +599,7 @@ function docSyncLeafGates(options: {
     pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format' }),
     pnpmScript('archived-agent-notes', 'verify-archived-agent-notes', { label: 'archived agent notes' }),
     pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
+    pnpmScript('skill-invocation-metadata', 'verify-skill-invocation-metadata', { label: 'skill invocation metadata' }),
     pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
     pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
     pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
scripts/snapshots/translation-prompt-v4/request-response.expected.json


+ 53 - 0
scripts/verify-skill-invocation-metadata.spec.ts

@@ -0,0 +1,53 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { collectSkillInvocationMetadataViolations } from './verify-skill-invocation-metadata.ts'
+
+const roots: string[] = []
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function fixtureRoot(): string {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-skill-invocation-metadata-'))
+  roots.push(root)
+  return root
+}
+
+function writeSkill(root: string, name: string, frontmatter: string, policy = ''): void {
+  const directory = join(root, '.agents/skills', name)
+  mkdirSync(join(directory, 'agents'), { recursive: true })
+  writeFileSync(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Test skill\n${frontmatter}---\n\nTest.\n`)
+  writeFileSync(
+    join(directory, 'agents/openai.yaml'),
+    `interface:\n  display_name: "Test"\n${policy}`,
+  )
+}
+
+describe('cross-product skill invocation metadata gate', () => {
+  it('accepts aligned default and manual-only policies', () => {
+    const root = fixtureRoot()
+    writeSkill(root, 'default-skill', '')
+    writeSkill(
+      root,
+      'manual-skill',
+      'disable-model-invocation: true\nuser-invocable: true\n',
+      'policy:\n  allow_implicit_invocation: false\n',
+    )
+
+    expect(collectSkillInvocationMetadataViolations(root)).toEqual([])
+  })
+
+  it('rejects either direction of a manual-only policy mismatch', () => {
+    const root = fixtureRoot()
+    writeSkill(root, 'claude-only', 'disable-model-invocation: true\n')
+    writeSkill(root, 'codex-only', '', 'policy:\n  allow_implicit_invocation: false\n')
+
+    expect(collectSkillInvocationMetadataViolations(root)).toEqual([
+      '.agents/skills/claude-only: Claude Code manual-only=true but Codex manual-only=false',
+      '.agents/skills/codex-only: Claude Code manual-only=false but Codex manual-only=true',
+    ])
+  })
+})

+ 122 - 0
scripts/verify-skill-invocation-metadata.ts

@@ -0,0 +1,122 @@
+/**
+ * Keep Claude Code and Codex invocation metadata aligned for repository skills.
+ * @module scripts/verify-skill-invocation-metadata
+ */
+
+import { existsSync, readFileSync, readdirSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { load } from 'js-yaml'
+
+const ROOT = resolve(import.meta.dirname, '..')
+
+/** Return an object-shaped YAML value, or undefined for every other shape. */
+function asRecord(value: unknown): Record<string, unknown> | undefined {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+    ? value as Record<string, unknown>
+    : undefined
+}
+
+/** Parse a skill's YAML frontmatter as an object. */
+function parseSkillFrontmatter(source: string): Record<string, unknown> {
+  const lines = source.split('\n')
+  if (lines[0] !== '---') throw new Error('SKILL.md must start with YAML frontmatter')
+  const end = lines.indexOf('---', 1)
+  if (end < 0) throw new Error('SKILL.md frontmatter is not closed')
+  const metadata = asRecord(load(lines.slice(1, end).join('\n')))
+  if (metadata === undefined) throw new Error('SKILL.md frontmatter must be a YAML object')
+  return metadata
+}
+
+/** Find repository skill directories that carry Codex product metadata. */
+function skillDirectories(root: string): string[] {
+  const skillsRoot = resolve(root, '.agents/skills')
+  if (!existsSync(skillsRoot)) return []
+  return readdirSync(skillsRoot, { withFileTypes: true })
+    .filter(entry => entry.isDirectory() && existsSync(resolve(skillsRoot, entry.name, 'agents/openai.yaml')))
+    .map(entry => entry.name)
+    .sort()
+}
+
+/**
+ * Report cross-product invocation-policy mismatches for repository skills.
+ * @param root - Repository root containing `.agents/skills`.
+ * @returns diagnostics for malformed metadata or policies that expose a skill differently.
+ */
+export function collectSkillInvocationMetadataViolations(root: string): string[] {
+  const violations: string[] = []
+
+  for (const skill of skillDirectories(root)) {
+    const relativeRoot = `.agents/skills/${skill}`
+    const skillFile = resolve(root, relativeRoot, 'SKILL.md')
+    const openaiFile = resolve(root, relativeRoot, 'agents/openai.yaml')
+    if (!existsSync(skillFile)) {
+      violations.push(`${relativeRoot}: agents/openai.yaml has no sibling SKILL.md`)
+      continue
+    }
+
+    let frontmatter: Record<string, unknown>
+    let openai: Record<string, unknown>
+    try {
+      frontmatter = parseSkillFrontmatter(readFileSync(skillFile, 'utf8'))
+    }
+    catch (error) {
+      violations.push(`${relativeRoot}/SKILL.md: ${error instanceof Error ? error.message : String(error)}`)
+      continue
+    }
+    try {
+      const parsed = asRecord(load(readFileSync(openaiFile, 'utf8')))
+      if (parsed === undefined) throw new Error('agents/openai.yaml must be a YAML object')
+      openai = parsed
+    }
+    catch (error) {
+      violations.push(`${relativeRoot}/agents/openai.yaml: ${error instanceof Error ? error.message : String(error)}`)
+      continue
+    }
+
+    const disableModelInvocation = frontmatter['disable-model-invocation']
+    if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
+      violations.push(`${relativeRoot}/SKILL.md: disable-model-invocation must be a boolean`)
+      continue
+    }
+    const userInvocable = frontmatter['user-invocable']
+    if (userInvocable !== undefined && typeof userInvocable !== 'boolean') {
+      violations.push(`${relativeRoot}/SKILL.md: user-invocable must be a boolean`)
+      continue
+    }
+
+    const policy = asRecord(openai.policy)
+    const allowImplicitInvocation = policy?.allow_implicit_invocation
+    if (allowImplicitInvocation !== undefined && typeof allowImplicitInvocation !== 'boolean') {
+      violations.push(`${relativeRoot}/agents/openai.yaml: policy.allow_implicit_invocation must be a boolean`)
+      continue
+    }
+
+    const claudeManualOnly = disableModelInvocation === true
+    const codexManualOnly = allowImplicitInvocation === false
+    if (claudeManualOnly !== codexManualOnly) {
+      violations.push(
+        `${relativeRoot}: Claude Code manual-only=${String(claudeManualOnly)}`
+        + ` but Codex manual-only=${String(codexManualOnly)}`,
+      )
+    }
+    if (claudeManualOnly && userInvocable === false) {
+      violations.push(`${relativeRoot}/SKILL.md: a manual-only skill must remain user-invocable`)
+    }
+  }
+
+  return violations
+}
+
+if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
+  const skills = skillDirectories(ROOT)
+  const violations = collectSkillInvocationMetadataViolations(ROOT)
+  if (violations.length > 0) {
+    process.stderr.write('verify-skill-invocation-metadata: violations found:\n')
+    for (const violation of violations) process.stderr.write(`  ${violation}\n`)
+    process.exit(1)
+  }
+
+  process.stdout.write(
+    `verify-skill-invocation-metadata: ${String(skills.length)} cross-product skill policy pair(s) aligned.\n`,
+  )
+}

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff