fix(skill): enforce manual invocation policy
This commit is contained in:
+2
-2
@@ -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
@@ -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
@@ -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` 会拒绝该状态。
|
||||
@@ -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
@@ -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".
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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`,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user