✨ feat(vscode): 重写注释把原型存进 docs/prototype/<功能>/openpencil/

跟写注释技能 ECC v2.2.53 的原型约定:去掉临时草稿目录 spx-comment-<id> 和 */prototypes 的建目录规则,会话 id 交给 Claude Code 起。
放开 Edit/Write 到 docs/prototype/*/openpencil/ 下、mkdir -p 到 docs/prototype/*/openpencil/exports;Bash 规则的 * 能跨空格,另外拒掉建目录时多跟一个路径的写法。
This commit is contained in:
cruldra committed 2026-10-12 15:11:10 +11:00
1 parent 5bfa89e691
commit b7c29c60e1
3 files changed
+43 -66

No files matched your search

+24 -33
View File
@@ -171,11 +171,10 @@ describe('失败了给人看的话', () => {
})
describe('重写注释的命令行参数', () => {
const SCRATCH = '/private/var/folders/T/spx-comment-s1'
const allowed = 'Read,Grep,Glob,Bash(/Applications/OpenPencil.app/Contents/MacOS/op *),Bash(mkdir -p */prototypes),Edit(//private/var/folders/T/spx-comment-s1/**)'
const allowed = 'Read,Grep,Glob,Bash(/Applications/OpenPencil.app/Contents/MacOS/op *),Bash(mkdir -p docs/prototype/*/openpencil/exports),Edit(/docs/prototype/*/openpencil/**)'
it('不读用户、项目的设置(--restricted),写注释的插件单独加载、能读;只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,别的一律拒、不加载 MCP;按 JSON Schema 交结果', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: SCRATCH, profilePath: '/p/sonnet.json', session: { start: 's1' } })
it('不读用户、项目的设置(--restricted),写注释的插件单独加载、能读;只放开读搜、OpenPencil 的 op、建原型的 exports 目录、改原型目录,别的一律拒、不加载 MCP;按 JSON Schema 交结果', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', profilePath: '/p/sonnet.json' })
expect(args).toEqual([
'--settings',
'/p/sonnet.json',
@@ -184,17 +183,15 @@ describe('重写注释的命令行参数', () => {
'/plugins/ecc',
'--add-dir',
'/plugins/ecc',
'--add-dir',
SCRATCH,
'--tools',
'Read,Grep,Glob,Bash,Edit,Write',
'--allowedTools',
allowed,
'--disallowedTools',
'Bash(mkdir -p docs/prototype/* *)',
'--permission-mode',
'dontAsk',
'--strict-mcp-config',
'--session-id',
's1',
'-p',
'--input-format',
'stream-json',
@@ -205,16 +202,21 @@ describe('重写注释的命令行参数', () => {
'--json-schema',
COMMENT_SCHEMA,
])
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions/)
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|spx-comment|prototypes\)/)
expect(JSON.parse(COMMENT_SCHEMA)).toEqual({ type: 'object', properties: { body: { type: 'string' } }, required: ['body'], additionalProperties: false })
})
it('接着说时 --resume 那次会话、不带 --session-id;没有 profile 就不带 --settings', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: SCRATCH, session: { resume: 's1' } })
it('接着说时 --resume 那次会话,放在 -p 前面;没有 profile 就不带 --settings', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', resume: 's1' })
expect(args[0]).toBe('--restricted')
expect(args).not.toContain('--session-id')
expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 's1', '-p'])
})
it('开新会话不带 --resume,会话 id 由 Claude Code 起', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc' })
expect(args).not.toContain('--resume')
expect(args).not.toContain('--session-id')
})
})
describe('写注释的插件在哪', () => {
@@ -245,25 +247,18 @@ describe('写注释的插件在哪', () => {
describe('重写注释问一次', () => {
const structured = (body: string, session = 's1'): string => line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify({ body }), structured_output: { body }, session_id: session })
/** 假的 claude 再加上找插件、建草稿目录、起会话 id:都记下来 */
/** 假的 claude 再加上找插件:问过哪个目录记下来 */
function commentDeps(runs: { lines: string[], error?: Error }[]) {
const { deps, calls } = fakeClaude(runs)
const scratches: string[] = []
const lookups: string[] = []
let next = 0
const commentDeps: CommentAskDeps = {
...deps,
pluginRoot: async (cwd) => {
lookups.push(cwd)
return '/plugins/ecc'
},
scratch: async (sessionId) => {
scratches.push(sessionId)
return `/tmp/spx-comment-${sessionId}`
},
newSessionId: () => `new-${++next}`,
}
return { deps: commentDeps, calls, scratches, lookups }
return { deps: commentDeps, calls, lookups }
}
function comment(overrides: Partial<CommentAsk> = {}): CommentAsk & { events: unknown[] } {
@@ -271,35 +266,31 @@ describe('重写注释问一次', () => {
return { prompt: '/ecc:writing-code-comments src/a.py:3-8', images: [], cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides }
}
it('开新会话:自己起会话 id、按它建草稿目录,带上插件目录和超时;stdin 里是那句提示和贴的图;返回正文和会话 id', async () => {
const { deps, calls, scratches, lookups } = commentDeps([{ lines: [structured('按名字找文件。', 'new-1')] }])
it('开新会话:带上插件目录和超时;stdin 里是那句提示和贴的图;返回正文和 Claude Code 起的会话 id', async () => {
const { deps, calls, lookups } = commentDeps([{ lines: [init('new-1'), structured('按名字找文件。', 'new-1')] }])
const ask = comment({ profilePath: '/p/sonnet.json', images: [PNG] })
expect(await askCommentBody(ask, deps)).toEqual({ body: '按名字找文件。', sessionId: 'new-1' })
expect(lookups).toEqual(['/work'])
expect(scratches).toEqual(['new-1'])
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: '/tmp/spx-comment-new-1', profilePath: '/p/sonnet.json', session: { start: 'new-1' } }))
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', profilePath: '/p/sonnet.json' }))
expect(calls[0]).toMatchObject({ cwd: '/work', signal: ask.signal, timeoutMs: REWRITE_TIMEOUT_MS })
expect(sent(calls[0])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-8', images: [{ source: { data: PNG.base64 } }] })
})
it('接着说:--resume 这块最近一次重写的会话,用那个会话的草稿目录,发块现在在哪和用户的话', async () => {
const { deps, calls, scratches } = commentDeps([{ lines: [structured('短一点。', 's0')] }])
it('接着说:--resume 这块最近一次重写的会话,发块现在在哪和用户的话', async () => {
const { deps, calls } = commentDeps([{ lines: [structured('短一点。', 's0')] }])
expect(await askCommentBody(comment({ followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } }), deps)).toEqual({ body: '短一点。', sessionId: 's0' })
expect(scratches).toEqual(['s0'])
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: '/tmp/spx-comment-s0', session: { resume: 's0' } }))
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', resume: 's0' }))
expect(sent(calls[0]).text).toBe('src/a.py:3-10\n\n短一点')
})
it('那次会话找不到了:开新会话按技能命令重新来,图片照样带上', async () => {
const { deps, calls, scratches } = commentDeps([
const { deps, calls } = commentDeps([
{ lines: [line({ type: 'result', subtype: 'error_during_execution', is_error: true, errors: ['No conversation found with session ID: s0'], session_id: 's0' })] },
{ lines: [structured('重新写的。', 'new-1')] },
])
const ask = comment({ prompt: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [PNG], followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } })
expect(await askCommentBody(ask, deps)).toEqual({ body: '重新写的。', sessionId: 'new-1' })
expect(scratches).toEqual(['s0', 'new-1'])
expect(calls[1].args).toContain('--session-id')
expect(calls[1].args).not.toContain('--resume')
expect(calls[1].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc' }))
expect(sent(calls[1])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [{ source: { data: PNG.base64 } }] })
})
+17 -31
View File
@@ -6,10 +6,6 @@
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { CommentEvent } from '../commentRewrite'
import type { AnswerEvent } from './answerStream'
import { randomUUID } from 'node:crypto'
import { mkdir, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { buildStreamJsonLine, CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude'
import { COMMENT_PLUGIN, COMMENT_SCHEMA, createCommentParser } from '../commentRewrite'
import { createAnswerParser } from './answerStream'
@@ -25,6 +21,9 @@ export const REWRITE_TIMEOUT_MS = 15 * 60_000
/** OpenPencil 装在应用里的命令行(写注释的技能用它画前端组件的原型图) */
export const OPENPENCIL_CLI = '/Applications/OpenPencil.app/Contents/MacOS/op'
/** 写注释的技能存原型的目录,`*` 是功能名(`my-apps`)。权限规则里写成 `Edit(/…)`,开头的 `/` 指 claude 的工作目录(仓库根目录),不是磁盘根目录。 */
const PROTOTYPE_DIR = 'docs/prototype/*/openpencil'
/**
* 一次提问的命令行参数;问题连同贴的图片从 stdin 按 stream-json 发(见 askClaude),没贴图也一样。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`),
* 别的一律拒绝、不等人批(`--permission-mode dontAsk`,盖过 profile 里的 bypassPermissions);
@@ -53,14 +52,15 @@ export function askArgs(options: { profilePath?: string, resume?: string } = {})
/**
* 一次重写注释的命令行参数,按 COMMENT_SCHEMA 交结构化结果(`--json-schema`)。除了读、搜,只放开写注释的技能画原型图要的:
* 运行 OpenPencil 的 op、建组件旁边的 `prototypes/` 目录、在草稿目录里写 build.js(`--allowedTools` 里的规则),别的一律拒绝、不等人批。
* 运行 OpenPencil 的 op、建原型的 `exports/` 目录、改 [PROTOTYPE_DIR] 里的文件(`--allowedTools` 里的规则),别的一律拒绝、不等人批。
* 规则里的 `*` 能跨空格,`mkdir -p docs/prototype/x/openpencil/exports src/y` 也对得上,所以建目录时多跟一个路径的另外拒掉(`--disallowedTools`)。
* `--restricted` 不读用户、项目的设置文件,那里放行的命令(比如 `git -C`)进不来,文件工具也只认工作目录;
* 写注释的插件因此要单独加载(`--plugin-dir`),它的目录和草稿目录加进工作目录(`--add-dir`),技能才读得到它的参考文档、写得了草稿。
* session:新会话用 start 当会话 id(草稿目录按它起名),接着说时 resume 那次会话。
* 写注释的插件因此要单独加载(`--plugin-dir`),它的目录加进工作目录(`--add-dir`),技能才读得到它的参考文档。
* 有 resume 就接着那次会话说,没有就开新会话,会话 id 由 Claude Code 起。
*/
export function rewriteArgs(options: { pluginRoot: string, scratch: string, profilePath?: string, session: { start: string } | { resume: string } }): string[] {
const { pluginRoot, scratch, profilePath, session } = options
const allowed = [READ_ONLY_TOOLS, `Bash(${OPENPENCIL_CLI} *)`, 'Bash(mkdir -p */prototypes)', `Edit(/${scratch}/**)`].join(',')
export function rewriteArgs(options: { pluginRoot: string, profilePath?: string, resume?: string }): string[] {
const { pluginRoot, profilePath, resume } = options
const allowed = [READ_ONLY_TOOLS, `Bash(${OPENPENCIL_CLI} *)`, `Bash(mkdir -p ${PROTOTYPE_DIR}/exports)`, `Edit(/${PROTOTYPE_DIR}/**)`].join(',')
return [
...(profilePath ? ['--settings', profilePath] : []),
'--restricted',
@@ -68,16 +68,16 @@ export function rewriteArgs(options: { pluginRoot: string, scratch: string, prof
pluginRoot,
'--add-dir',
pluginRoot,
'--add-dir',
scratch,
'--tools',
`${READ_ONLY_TOOLS},Bash,Edit,Write`,
'--allowedTools',
allowed,
'--disallowedTools',
'Bash(mkdir -p docs/prototype/* *)',
'--permission-mode',
'dontAsk',
'--strict-mcp-config',
...('resume' in session ? ['--resume', session.resume] : ['--session-id', session.start]),
...(resume ? ['--resume', resume] : []),
'-p',
'--input-format',
'stream-json',
@@ -186,24 +186,11 @@ export interface CommentAnswer {
export interface CommentAskDeps extends AskDeps {
/** 写注释的技能所在插件的目录,按 cwd 问(项目里装的插件也算) */
pluginRoot: (cwd: string) => Promise<string>
/** 这个会话画原型用的草稿目录,建好了返回 */
scratch: (sessionId: string) => Promise<string>
/** 新会话的 id */
newSessionId: () => string
}
/** 这个会话画原型的草稿目录:系统临时目录下的 `spx-comment-<会话 id>`,同一会话每轮都是它。用真实路径,权限规则按它比对。不删,留给系统清。 */
async function commentScratch(sessionId: string): Promise<string> {
const dir = path.join(await realpath(tmpdir()), `spx-comment-${sessionId}`)
await mkdir(dir, { recursive: true })
return dir
}
const COMMENT_DEPS: CommentAskDeps = {
runLines: runClaudeLines,
pluginRoot: cwd => commentPluginRoot(cwd),
scratch: commentScratch,
newSessionId: randomUUID,
}
/**
@@ -212,13 +199,12 @@ const COMMENT_DEPS: CommentAskDeps = {
*/
export async function askCommentBody(ask: CommentAsk, deps: CommentAskDeps = COMMENT_DEPS): Promise<CommentAnswer> {
const pluginRoot = await deps.pluginRoot(ask.cwd)
const once = async (prompt: string, session: { start: string } | { resume: string }): Promise<CommentAnswer> => {
const scratch = await deps.scratch('resume' in session ? session.resume : session.start)
const once = async (prompt: string, resume?: string): Promise<CommentAnswer> => {
const parse = createCommentParser()
let result: CommentAnswer | undefined
let failure: string | undefined
await deps.runLines({
args: rewriteArgs({ pluginRoot, scratch, profilePath: ask.profilePath, session }),
args: rewriteArgs({ pluginRoot, profilePath: ask.profilePath, resume }),
input: buildStreamJsonLine(prompt, ask.images),
cwd: ask.cwd,
signal: ask.signal,
@@ -242,14 +228,14 @@ export async function askCommentBody(ask: CommentAsk, deps: CommentAskDeps = COM
}
if (ask.followUp) {
try {
return await once(ask.followUp.prompt, { resume: ask.followUp.sessionId })
return await once(ask.followUp.prompt, ask.followUp.sessionId)
}
catch (err) {
if (!missingSession(err))
throw err
}
}
return once(ask.prompt, { start: deps.newSessionId() })
return once(ask.prompt)
}
/** `claude plugin list --json` 里启用着的 name 插件的目录;没装、没启用、读不出来时没有。 */