用 System One 模型(目前 TypeSafe Jev)做判斷的 hook:派 sub-agent 前選 model;grilling 收尾拆題成 context/specs 規格檔(需 TYPESAFE_API_KEY,停用 plugin 即關閉)

Claude Code mods(hooks plugins)。
grill-choices:/grill-with-docs 的提問改成 AskUserQuestion 選擇題,可自行輸入。ship:/ship [補充說明],驗證 → 獨立 sonnet code-review → 確認 commit 訊息 → commit → push。action-chips:每次回覆後在輸入框上方排出小框框——回覆中的檔案與網址點了開啟(可執行檔只在 Finder 顯示)、指令點了複製;標籤由 haiku 寫成「這是什麼」(如 web demo、部署指令),依回覆順序由左至右、過長換行。system1:hook,派 sub-agent(Agent 工具)前用 System One 模型(目前後端為 TypeSafe Jev)自動決定 model。需環境變數 TYPESAFE_API_KEY(沒有就不動);名稱含 grill 或 plan 的 skill(如 /grilling、/grill-with-docs)收尾時,會問是否把結論拆成原子規格條目,由 system1:planner(opus)寫入 context/specs/<id>.json,經程式與 Jev 檢查、使用者確認後生效。可用 /plugin disable system1@cch 關閉、/plugin enable system1@cch 開啟。隱私:每次派 sub-agent,description 與 prompt 前 6000 字會送到 api.typesafe.ai。/plugin marketplace add cch-agent/claude-mods
/plugin install grill-choices@cch
/plugin install ship@cch
/plugin install action-chips@cch
/plugin install system1@cch
/reload-plugins
marketplace 名稱為
cch(見.claude-plugin/marketplace.json),安裝與解除安裝都用@cch。從舊名my-mods遷移:先/plugin uninstall <mod>@my-mods、/plugin marketplace remove my-mods,再依上方步驟重裝。
hooks/register.ts 534 lines1import type { Register } from 'claude-code'
2
3import {
4 MAX_ITEMS,
5 buildItemRequest,
6 flagSuspicious,
7 formatReport,
8 isGuardedSpecPath,
9 judgeItem,
10 makeSpecId,
11 parseItemAnswer,
12 roundState,
13 validateSpec,
14 type Checks,
15 type Layer3,
16 type Spec,
17} from './plan'
18import { PLANNER_PROMPT, SPEC_TAIL } from './prompts'
19
20export type Model = 'opus' | 'sonnet' | 'haiku'
21export type QuestionId =
22 | 'needs_architecture'
23 | 'needs_deep_reasoning'
24 | 'design_settled'
25 | 'modifies_code'
26 | 'lookup_or_chore'
27export type Nouls = Record<QuestionId, number>
28
29export const HIGH = 0.65
30export const LOW = 0.35
31export const API_URL = 'https://api.typesafe.ai/v1/systemone'
32export const TIMEOUT_MS = 3000
33export const PROMPT_LIMIT = 6000
34
35const QUESTION_TEXT: Record<QuestionId, string> = {
36 needs_architecture: '任務是否需要做架構設計,或在多個方案間做取捨決策?',
37 needs_deep_reasoning: '任務是否需要深度分析或多步推理才能完成?',
38 design_settled: '做法是否已定案,只需照著既定規格執行?',
39 modifies_code: '任務是否需要撰寫或修改程式碼、測試,或審查程式碼?',
40 lookup_or_chore: '任務是否只是查找資料、搜尋檔案、格式化輸出或重複性操作?',
41}
42const IDS = Object.keys(QUESTION_TEXT) as QuestionId[]
43
44const uncertain = (v: number) => v > LOW && v < HIGH
45
46/** 五題 Noul 答案 → model;null 代表不覆寫。判斷順序內只檢查該分支用到的題目是否落在不確定帶 (0.35, 0.65)。 */
47export function decideModel(n: Nouls): Model | null {
48 // opus 分支:max(needs_architecture, needs_deep_reasoning) 與 design_settled
49 const arch = Math.max(n.needs_architecture, n.needs_deep_reasoning)
50 if (uncertain(arch)) return null
51 if (arch >= HIGH) {
52 if (uncertain(n.design_settled)) return null
53 if (n.design_settled < HIGH) return 'opus'
54 }
55 // haiku 分支:lookup_or_chore 與 modifies_code
56 if (uncertain(n.lookup_or_chore)) return null
57 if (n.lookup_or_chore >= HIGH) {
58 if (uncertain(n.modifies_code)) return null
59 if (n.modifies_code <= LOW) return 'haiku'
60 }
61 // sonnet 分支:modifies_code 或 design_settled 明確為是
62 if (n.modifies_code >= HIGH || n.design_settled >= HIGH) return 'sonnet'
63 return null
64}
65
66export function buildRequest(description: string, prompt: string) {
67 const questions: Record<string, unknown> = {}
68 for (const id of IDS) {
69 questions[id] = {
70 type: 'noul',
71 instructions: { task: '`prompt`', question: QUESTION_TEXT[id] },
72 }
73 }
74 return {
75 model: 'jev-latest',
76 state: { description, prompt: prompt.slice(0, PROMPT_LIMIT) },
77 questions,
78 }
79}
80
81/** 解析 API 回應;任何欄位缺漏或格式不對回 null。 */
82export function parseNouls(text: string): Nouls | null {
83 try {
84 const answers = (JSON.parse(text) as { answers?: Record<string, { noul?: unknown }> }).answers
85 if (!answers || typeof answers !== 'object') return null
86 const out = {} as Nouls
87 for (const id of IDS) {
88 const v = answers[id]?.noul
89 if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1) return null
90 out[id] = v
91 }
92 return out
93 } catch {
94 return null
95 }
96}
97
98export function statusText(chosen: Model | null, original: string | undefined): string {
99 if (chosen === null) return `保留 ${original ?? '未指定'}`
100 return `${chosen}` + (chosen !== original ? `(原 ${original ?? '未指定'})` : '')
101}
102
103
104const SUBMIT = 'mcp__system1__submit_plan'
105const CONFIRM = 'mcp__system1__confirm_plan'
106export const LAYER3_CONCURRENCY = 4
107export const LAYER3_TIMEOUT_MS = 8000
108const GUARDED_TOOLS = ['Write', 'Edit', 'NotebookEdit'] as const
109
110// skill 名稱(含 plugin 前綴)含 grill 或 plan 即視為需要收尾拆題的規格類 skill,不分大小寫
111export const isSpecSkill = (skill: string) => /grill|plan/i.test(skill)
112
113const SUBMIT_SCHEMA = {
114 type: 'object',
115 properties: {
116 spec_id: { type: 'string', description: '修改既有規格時帶上;第一次送出不要帶' },
117 slug: { type: 'string', description: '英數與連字號的短名稱,用於組成 spec id' },
118 intent: { type: 'string', description: '一句話本意' },
119 requirements: {
120 type: 'array',
121 items: {
122 type: 'object',
123 properties: {
124 id: { type: 'string', description: 'R1、R2…(可加一個小寫字母後綴)' },
125 text: { type: 'string' },
126 verify_by: { type: 'string', enum: ['test', 'jev', 'agent'] },
127 },
128 required: ['id', 'text', 'verify_by'],
129 },
130 },
131 out_of_scope: { type: 'array', items: { type: 'string' } },
132 open_questions: {
133 type: 'array',
134 items: {
135 type: 'object',
136 properties: {
137 req_id: { type: 'string' },
138 question: { type: 'string' },
139 options: { type: 'array', items: { type: 'string' } },
140 recommended: { type: 'integer' },
141 why: { type: 'string' },
142 },
143 required: ['req_id', 'question', 'options', 'recommended', 'why'],
144 },
145 },
146 },
147 required: ['slug', 'intent', 'requirements', 'out_of_scope'],
148}
149
150const CONFIRM_SCHEMA = {
151 type: 'object',
152 properties: { spec_id: { type: 'string', description: '要確認的規格 id' } },
153 required: ['spec_id'],
154}
155
156const SPEC_ID_RE = /^[A-Za-z0-9-]+$/
157
158/** 不信任檔案內容的 id:必須合法且等於檔名(去 .json),否則視為無效。 */
159const parseSpec = (text: string, fileId: string): Spec | null => {
160 try {
161 const s = JSON.parse(text) as Spec
162 return s && s.schema === 1 && typeof s.id === 'string' && SPEC_ID_RE.test(s.id) && s.id === fileId ? s : null
163 } catch {
164 return null
165 }
166}
167
168type State = { root: Promise<string> | undefined; written: { seq: number; id: string; report: string } }
169const getRoot = ($: any, st: State): Promise<string> =>
170 (st.root ??= (async () => {
171 try {
172 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
173 const top = String(r.stdout ?? '').trim()
174 if (r.exitCode === 0 && top) return top
175 } catch {
176 // 退回 cwd
177 }
178 return $.session.cwd()
179 })())
180
181const specsDir = (root: string) => `${root}/context/specs`
182
183const listSpecFiles = async ($: any, root: string) => {
184 const dir = specsDir(root)
185 let entries: { name: string; kind: string }[]
186 try {
187 entries = await $.fs.list(dir)
188 } catch {
189 return []
190 }
191 const out: { name: string; text: string }[] = []
192 for (const en of entries) {
193 if (en.kind !== 'file' || !en.name.endsWith('.json')) continue
194 try {
195 out.push({ name: en.name, text: (await $.fs.read(`${dir}/${en.name}`)) as string })
196 } catch {
197 // 讀不到的略過
198 }
199 }
200 return out
201}
202
203const readSpec = async ($: any, root: string, id: string): Promise<Spec | null> => {
204 try {
205 return parseSpec((await $.fs.read(`${specsDir(root)}/${id}.json`)) as string, id)
206 } catch {
207 return null
208 }
209}
210
211const writeSpec = ($: any, root: string, id: string, spec: Spec) =>
212 $.fs.write(`${specsDir(root)}/${id}.json`, JSON.stringify({ ...spec, id }, null, 2) + '\n') as Promise<void>
213
214/** 第 3 層:每條一個請求,併發 4、總逾時 8 秒;失敗只降級,不拋錯。 */
215const runLayer3 = async ($: any, key: string | undefined, reqs: Spec['requirements'], signal: AbortSignal): Promise<Layer3> => {
216 if (!key) return { status: 'skipped:no-key', items: {} }
217 const sent = reqs.slice(0, MAX_ITEMS)
218 const items: Layer3['items'] = {}
219 let errors = 0
220 let stopped = false
221 let cursor = 0
222 const worker = async () => {
223 while (!stopped && cursor < sent.length) {
224 const req = sent[cursor++]!
225 try {
226 const r = await $.http.fetch(API_URL, {
227 method: 'POST',
228 headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
229 body: JSON.stringify(buildItemRequest(req)),
230 })
231 const a = r.ok ? parseItemAnswer(r.text, req.verify_by) : null
232 if (stopped) return
233 if (a) items[req.id] = { ...a, verdict: judgeItem(a) }
234 else errors++
235 } catch {
236 if (!stopped) errors++ // 不記錄 headers/key
237 }
238 }
239 }
240 const abort = new AbortController()
241 const onStop = () => abort.abort()
242 if (signal.aborted) onStop()
243 else signal.addEventListener('abort', onStop)
244 try {
245 const all = Promise.all(Array.from({ length: Math.min(LAYER3_CONCURRENCY, sent.length) }, worker)).then(() => true)
246 const timeout = $.clock.sleep(LAYER3_TIMEOUT_MS, { signal: abort.signal }).then(
247 () => false,
248 () => false,
249 )
250 const finished = await Promise.race([all, timeout])
251 all.catch(() => {})
252 stopped = true
253 const got = Object.keys(items).length
254 const timedOut = !finished
255 if (got === sent.length) return { status: 'ok', items }
256 if (timedOut) return { status: got === 0 ? 'timeout' : 'partial-timeout', items }
257 return { status: 'error', items }
258 } finally {
259 stopped = true
260 abort.abort()
261 signal.removeEventListener('abort', onStop)
262 }
263}
264
265
266// 路徑落點:往上找第一個存在的祖先取 realPath,再接上不存在的剩餘段。
267// 剩餘段含 `..` 時回 'unsafe'(無法判斷落點,呼叫端視為受保護);無法解析回 undefined。
268const placed = async ($: any, path: string): Promise<string | 'unsafe' | undefined> => {
269 const abs = path.startsWith('/') ? path : `${(await $.session.cwd()).replace(/\/$/, '')}/${path}`
270 const segs = abs.split('/').filter((x: string) => x !== '' && x !== '.')
271 const rest: string[] = []
272 while (true) {
273 const cur = '/' + segs.join('/')
274 const st = await $.fs.stat(cur, { resolve: true }).catch(() => undefined)
275 if (st?.realPath) {
276 if (rest.includes('..')) return 'unsafe'
277 const base = (st.realPath as string).replace(/\/$/, '')
278 return rest.length ? `${base}/${rest.join('/')}` : base
279 }
280 if (segs.length === 0) return undefined
281 rest.unshift(segs.pop()!)
282 }
283}
284
285
286export const register: Register = on => {
287 const state: State = { root: undefined, written: { seq: 0, id: '', report: '' } }
288
289 const textResult = (text: string) => ({ result: text })
290
291 on('session.start', async ($, e, next) => {
292 // 三個註冊各自獨立,一個失敗不連帶其他
293 try {
294 await $.tool.register({
295 name: 'submit_plan',
296 description:
297 '送出需求拆題後的規格(唯一寫入路徑):檢查格式與條目品質、寫入 context/specs/<id>.json,回傳檢查報告。只給 system1:planner 使用。',
298 inputSchema: SUBMIT_SCHEMA,
299 isDeferred: false,
300 })
301 } catch {
302 // 略過
303 }
304 try {
305 await $.tool.register({
306 name: 'confirm_plan',
307 description: '請使用者確認規格(spec_id)。確認後該規格才生效;只在拆題流程最後由主 session 呼叫。',
308 inputSchema: CONFIRM_SCHEMA,
309 isDeferred: false,
310 })
311 } catch {
312 // 略過
313 }
314 try {
315 await $.agent.register({
316 name: 'planner',
317 description: '把需求簡報拆成原子、可驗證的規格條目並送出檢查(grilling 收尾拆題時使用,傳入需求簡報全文)。',
318 prompt: PLANNER_PROMPT,
319 tools: ['Read', 'Bash', SUBMIT],
320 model: 'opus',
321 maxTurns: 40,
322 })
323 } catch {
324 // 略過
325 }
326 return next(e)
327 }).catch(($, e, next) => next(e))
328
329 // planner 必須在前景跑,Agent 工具才會等到它結束,hook 才附得上摘要
330 on('agent.spawn', ($, e, next) => (e.subagentType === 'system1:planner' ? next({ ...e, background: false }) : next(e))).catch(
331 ($, e, next) => next(e),
332 )
333
334 on('tool.call', { tool: SUBMIT }, async ($, e: any, next) => {
335 const input = {
336 spec_id: e.spec_id,
337 slug: e.slug,
338 intent: e.intent,
339 requirements: e.requirements,
340 out_of_scope: e.out_of_scope,
341 open_questions: e.open_questions,
342 }
343 const v = validateSpec(input)
344 if (!v.ok) return textResult(`格式錯誤,未寫入:\n${v.errors.map(x => `- ${x}`).join('\n')}\n請修正後重送。`)
345 const root = await getRoot($, state)
346 const files = await listSpecFiles($, root)
347 const existing = new Set(files.map(f => f.name.replace(/\.json$/, '')))
348
349 let prev: Spec | null = null
350 let id: string
351 if (v.value.spec_id !== undefined) {
352 if (!SPEC_ID_RE.test(v.value.spec_id)) return textResult('spec_id 格式不符,未寫入。')
353 prev = await readSpec($, root, v.value.spec_id)
354 if (!prev) return textResult(`找不到 spec_id ${v.value.spec_id},未寫入。第一次送出請不要帶 spec_id。`)
355 id = v.value.spec_id
356 } else {
357 id = makeSpecId(new Date(await $.clock.now()), v.value.slug, existing)
358 }
359
360 const agentKey: string = e.agentId ?? 'main'
361 const st = roundState(prev?.checks, agentKey)
362 if (st.decision === 'reject') {
363 const stop = !st.askAllowed || st.revision >= 4 ? '不再提問;' : ''
364 return textResult(`規格 ${id} 已達重寫上限(第 ${st.round} 輪、revision ${st.revision}),拒絕修改、未寫入。${stop}請回報 spec_id 與未通過條目。`)
365 }
366
367 const layer2: Record<string, string[]> = {}
368 for (const r of v.value.requirements) layer2[r.id] = flagSuspicious(r.text)
369 const key = (await $.env.get('TYPESAFE_API_KEY')) as string | undefined
370 const layer3 = await runLayer3($, key, v.value.requirements, next.signal)
371 const checks: Checks = { round: st.round, revision: st.revision, agent: agentKey, layer2, layer3 }
372
373 // 狀態、時間、base_commit、checks 一律由 mod 決定;修改過的規格(含 confirmed)回到 draft
374 const spec: Spec = {
375 schema: 1,
376 id,
377 status: 'draft',
378 created_at: prev?.created_at ?? new Date(await $.clock.now()).toISOString(),
379 intent: v.value.intent,
380 requirements: v.value.requirements,
381 out_of_scope: v.value.out_of_scope,
382 open_questions: v.value.open_questions ?? [],
383 checks,
384 }
385 if (next.signal.aborted) return textResult('呼叫已中斷,未寫入。')
386 await writeSpec($, root, id, spec)
387 const report = formatReport(spec, { decision: st.decision, askAllowed: st.askAllowed })
388 state.written = { seq: state.written.seq + 1, id, report }
389 const note = v.value.requirements.length > MAX_ITEMS ? `\n註:超過 ${MAX_ITEMS} 條,後面的條目未送第 3 層檢查。` : ''
390 return textResult(report + note)
391 }).catch(($, e, next) => (next.called ? next(e) : { result: 'submit_plan 內部錯誤,未寫入。' }))
392
393 on('tool.call', { tool: CONFIRM }, async ($, e: any, next) => {
394 const id = String(e.spec_id ?? '')
395 if (!SPEC_ID_RE.test(id)) return textResult('spec_id 格式不符。')
396 const root = await getRoot($, state)
397 const spec = await readSpec($, root, id)
398 if (!spec) return textResult(`找不到規格 ${id}。`)
399 if (spec.status === 'confirmed') return textResult(`規格 ${id} 已是 confirmed。`)
400
401 const flagged = spec.requirements.filter(
402 r => (spec.checks.layer2[r.id]?.length ?? 0) > 0 || (spec.checks.layer3.items[r.id] && spec.checks.layer3.items[r.id]!.verdict !== 'pass'),
403 ).length
404 const q = `規格 ${id}:共 ${spec.requirements.length} 條,${flagged} 條有標記,${spec.open_questions.length} 個未解問題,是否確認?`
405 const before = spec.checks
406 let ans: string
407 try {
408 ans = await $.ui.ask(q, ['確認', '修改', '取消'])
409 } catch {
410 return textResult(`無法詢問使用者(非互動模式或已關閉),規格 ${id} 維持 draft。`)
411 }
412 if (ans === '取消') return textResult(`使用者取消,規格 ${id} 維持 draft。`)
413 if (ans !== '確認') {
414 const extra = ans === '修改' ? '' : `:${ans}`
415 return textResult(`使用者要求修改${extra}。規格 ${id} 維持 draft,請依意見調整後帶 spec_id 重派 planner。`)
416 }
417
418 // 詢問期間規格可能被改寫:重讀,輪數/revision/agent 有變就不確認
419 const fresh = await readSpec($, root, id)
420 if (!fresh || fresh.checks.round !== before.round || fresh.checks.revision !== before.revision || fresh.checks.agent !== before.agent) {
421 return textResult(`規格 ${id} 已更新,請重新確認。`)
422 }
423 if (next.signal.aborted) return textResult('呼叫已中斷,規格狀態未變。')
424 let base: string | undefined
425 try {
426 const r = await $.process.run(['git', 'rev-parse', 'HEAD'], { cwd: root })
427 if (r.exitCode === 0 && r.stdout.trim()) base = r.stdout.trim()
428 } catch {
429 // 無 git 時不記 base_commit
430 }
431 const confirmed: Spec = {
432 ...spec,
433 status: 'confirmed',
434 confirmed_at: new Date(await $.clock.now()).toISOString(),
435 ...(base ? { base_commit: base } : {}),
436 }
437 await writeSpec($, root, id, confirmed)
438 // 盡力把其他 confirmed 標成 superseded;生效規則不依賴它
439 for (const f of await listSpecFiles($, root)) {
440 const fileId = f.name.replace(/\.json$/, '')
441 const other = parseSpec(f.text, fileId)
442 if (!other || fileId === id || other.status !== 'confirmed') continue
443 try {
444 await writeSpec($, root, fileId, { ...other, status: 'superseded' })
445 } catch {
446 // 略過
447 }
448 }
449 return textResult(`已確認規格 ${id}\n路徑:${specsDir(root)}/${id}.json\nbase_commit:${base ?? '(無)'}\n實作從使用者下一個指令開始。`)
450 }).catch(($, e, next) => (next.called ? next(e) : { result: 'confirm_plan 內部錯誤,規格狀態未變。' }))
451
452 // 寫入防護:規格檔只能經 submit_plan 修改
453 for (const tool of GUARDED_TOOLS) {
454 on('tool.call', { tool: tool as 'Write' }, async ($, e: any, next) => {
455 const path: unknown = e.file_path ?? e.notebook_path
456 if (typeof path !== 'string' || !path) return next(e)
457 const root = await getRoot($, state)
458 const real = await placed($, path)
459 if (!real) return next(e)
460 const DENY = { deny: '規格檔只能經 mcp__system1__submit_plan(帶 spec_id)修改;修改後需重新確認' }
461 if (real === 'unsafe') return DENY // 不存在的路徑段含 `..`,落點無法判斷
462 const top = (await $.fs.stat(root, { resolve: true }).catch(() => undefined))?.realPath ?? root
463 // 比對不分大小寫(macOS 預設大小寫不敏感)
464 const lower = real.toLowerCase()
465 if (isGuardedSpecPath(lower, top.toLowerCase()) || isGuardedSpecPath(lower, root.toLowerCase())) return DENY
466 return next(e)
467 }).catch(($, e, next) => next(e))
468 }
469
470 // 同一回合只附加一次(grill-with-docs 會再載入 grilling,兩者都命中)。
471 // 旗標存在閉包內;熱重載會重設為 false,最多多附加一次,可接受。
472 let appendedThisTurn = false
473 on('prompt.submit', ($, e, next) => {
474 appendedThisTurn = false
475 return next(e)
476 }).catch(($, e, next) => next(e))
477
478 on('skill.prompt', ($, e, next) => {
479 if (isSpecSkill(e.skill) && !appendedThisTurn && !e.text.includes(SPEC_TAIL)) {
480 appendedThisTurn = true
481 return next({ ...e, text: e.text + (SPEC_TAIL.startsWith('\n') ? '' : '\n\n') + SPEC_TAIL })
482 }
483 return next(e)
484 }).catch(($, e, next) => next(e))
485
486 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
487 if (e.subagent_type === 'fork') return next(e)
488 // 本 plugin 自己的 agent:不打 Jev、不改 model;planner 回來後附上最新規格摘要
489 if (typeof e.subagent_type === 'string' && e.subagent_type.startsWith('system1:')) {
490 const before = state.written.seq
491 const res = await next(e)
492 if (e.subagent_type === 'system1:planner' && res.deny === undefined && state.written.seq !== before) {
493 const note = `system1 規格摘要(spec_id ${state.written.id}):\n${state.written.report}`
494 return { ...res, context: [...(res.context ?? []), note] } as typeof res
495 }
496 return res
497 }
498 const key = await $.env.get('TYPESAFE_API_KEY')
499 if (!key) return next(e)
500
501 let chosen: Model | null = null
502 const abort = new AbortController()
503 // 使用者中斷(next.signal)時,逾時等待一起結束;fetch 本身無法取消,只是不再等它
504 const onStop = () => abort.abort()
505 if (next.signal.aborted) onStop()
506 else next.signal.addEventListener('abort', onStop)
507 try {
508 const body = JSON.stringify(buildRequest(e.description, e.prompt))
509 const call = $.http
510 .fetch(API_URL, {
511 method: 'POST',
512 headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
513 body,
514 })
515 .then(r => (r.ok ? parseNouls(r.text) : null))
516 const timeout = $.clock.sleep(TIMEOUT_MS, { signal: abort.signal }).then(
517 () => null,
518 () => null,
519 )
520 const nouls = await Promise.race([call, timeout])
521 call.catch(() => {})
522 if (nouls) chosen = decideModel(nouls)
523 } catch {
524 chosen = null // 不記錄 headers/key
525 } finally {
526 abort.abort()
527 next.signal.removeEventListener('abort', onStop)
528 }
529
530 $.ui.status(statusText(chosen, e.model))
531 return chosen ? next({ ...e, model: chosen }) : next(e)
532 }).catch(($, e, next) => next(e))
533}
534hooks/plan.ts 278 lines1// 第 2 階段:grilling 收尾拆題 → 規格檔。本檔全為純函式,不依賴 `$`,方便單元測試。
2
3export const HIGH = 0.65,
4 LOW = 0.35,
5 MAX_ITEMS = 30
6export type VerifyBy = 'test' | 'jev' | 'agent'
7export interface Requirement { id: string; text: string; verify_by: VerifyBy }
8export interface OpenQuestion { req_id: string; question: string; options: string[]; recommended: number; why: string }
9export type Verdict = 'pass' | 'fail' | 'unsure'
10export interface ItemAnswer { single_condition: number; self_contained: number; testable?: number }
11export interface Layer3 { status: 'ok' | 'skipped:no-key' | 'timeout' | 'partial-timeout' | 'error'; items: Record<string, ItemAnswer & { verdict: Verdict }> }
12export interface Checks { round: number; revision: number; agent: string; layer2: Record<string, string[]>; layer3: Layer3 }
13export interface Spec { schema: 1; id: string; status: 'draft' | 'confirmed' | 'superseded'; created_at: string; confirmed_at?: string; base_commit?: string; intent: string; requirements: Requirement[]; out_of_scope: string[]; open_questions: OpenQuestion[]; checks: Checks }
14export interface SpecInput { spec_id?: string; slug: string; intent: string; requirements: Requirement[]; out_of_scope: string[]; open_questions?: OpenQuestion[] }
15
16const VERIFY_BY = ['test', 'jev', 'agent']
17const REQ_ID = /^R\d+[a-z]?$/
18const isStr = (v: unknown): v is string => typeof v === 'string'
19const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
20
21/** 驗證 planner 送來的規格輸入;回傳所有錯誤而非只第一個。 */
22export function validateSpec(input: unknown): { ok: true; value: SpecInput } | { ok: false; errors: string[] } {
23 if (!isObj(input)) return { ok: false, errors: ['輸入必須是物件'] }
24 const errors: string[] = []
25
26 if (!isStr(input.intent) || !input.intent.trim()) errors.push('缺少 intent(一句話本意)')
27
28 const ids = new Set<string>()
29 const reqs: Requirement[] = []
30 if (!Array.isArray(input.requirements) || input.requirements.length === 0) {
31 errors.push('requirements 不可為空')
32 } else {
33 for (const [i, r] of input.requirements.entries()) {
34 if (!isObj(r)) {
35 errors.push(`requirements[${i}] 必須是物件`)
36 continue
37 }
38 const label = isStr(r.id) ? r.id : `#${i}`
39 if (!isStr(r.id) || !REQ_ID.test(r.id)) errors.push(`${label}:id 須符合 ^R\\d+[a-z]?$`)
40 else if (ids.has(r.id)) errors.push(`${label}:id 重複`)
41 else ids.add(r.id)
42 if (!isStr(r.text) || !r.text.trim()) errors.push(`${label}:text 不可為空`)
43 if (!isStr(r.verify_by) || !VERIFY_BY.includes(r.verify_by)) errors.push(`${label}:verify_by 須為 test/jev/agent`)
44 reqs.push({ id: r.id as string, text: r.text as string, verify_by: r.verify_by as VerifyBy }) // 只留三個欄位
45 }
46 }
47
48 if (!Array.isArray(input.out_of_scope) || !input.out_of_scope.every(isStr)) errors.push('out_of_scope 須為字串陣列')
49
50 const oqs: OpenQuestion[] = []
51 if (input.open_questions !== undefined) {
52 if (!Array.isArray(input.open_questions)) errors.push('open_questions 須為陣列')
53 else {
54 for (const [i, q] of input.open_questions.entries()) {
55 const label = `open_questions[${i}]`
56 if (!isObj(q)) {
57 errors.push(`${label} 必須是物件`)
58 continue
59 }
60 if (!isStr(q.req_id) || !ids.has(q.req_id)) errors.push(`${label}:req_id 必須是存在的需求 id`)
61 if (!isStr(q.question) || !q.question.trim()) errors.push(`${label}:question 不可為空`)
62 if (!Array.isArray(q.options) || q.options.length < 2 || q.options.length > 4 || !q.options.every(isStr)) {
63 errors.push(`${label}:options 須為 2–4 個字串`)
64 } else if (!Number.isInteger(q.recommended) || (q.recommended as number) < 0 || (q.recommended as number) >= q.options.length) {
65 errors.push(`${label}:recommended 須為 options 的有效索引`)
66 }
67 if (!isStr(q.why)) errors.push(`${label}:why 須為字串`)
68 oqs.push({ req_id: q.req_id as string, question: q.question as string, options: q.options as string[], recommended: q.recommended as number, why: q.why as string })
69 }
70 }
71 }
72
73 if (input.spec_id !== undefined && !isStr(input.spec_id)) errors.push('spec_id 須為字串')
74 if (input.slug !== undefined && !isStr(input.slug)) errors.push('slug 須為字串')
75
76 if (errors.length) return { ok: false, errors }
77 const value: SpecInput = {
78 slug: isStr(input.slug) ? input.slug : '',
79 intent: input.intent as string,
80 requirements: reqs,
81 out_of_scope: input.out_of_scope as string[],
82 open_questions: oqs,
83 }
84 if (isStr(input.spec_id)) value.spec_id = input.spec_id
85 return { ok: true, value }
86}
87
88/** 第 2 層:只標記可疑處,不判對錯。 */
89export function flagSuspicious(text: string): string[] {
90 const flags: string[] = []
91 // 較長的詞先比對並從工作字串移除,避免「並且」同時被標成「並」「且」
92 let rest = text
93 for (const w of ['並且', '以及', '同時', '並', '且']) {
94 if (rest.includes(w)) {
95 flags.push(`conjunction:${w}`)
96 rest = rest.split(w).join(' ')
97 }
98 }
99 if (/,\s*and\b/i.test(text)) flags.push('conjunction:, and')
100 for (const w of ['剛才', '上述', '前述', '這個', '那個']) if (text.includes(w)) flags.push(`pronoun:${w}`)
101 if (/(?<!應)該/.test(text)) flags.push('pronoun:該') // 排除「應該」
102 if (/\babove\b/i.test(text)) flags.push('pronoun:above')
103 const len = Array.from(text).length
104 if (len > 80) flags.push(`long:${len}`)
105 return flags
106}
107
108/** 第 3 層:每條需求一個請求,state 只含該條。testable 只在 verify_by === 'test' 時問。 */
109export function buildItemRequest(req: Requirement): { model: 'jev-latest'; state: { requirement: string; verify_by: VerifyBy }; questions: Record<string, unknown> } {
110 const questions: Record<string, unknown> = {
111 single_condition: {
112 type: 'noul',
113 instructions: { question: '`requirement` 是否只描述一個可獨立判斷是否滿足的條件?' },
114 criteria: {
115 true: '只有一個條件,一次檢查即可判定',
116 false: '含兩個以上可分開判斷的條件,例如以「且/並/以及」連接,或同時規定行為與修改範圍',
117 },
118 },
119 self_contained: {
120 type: 'noul',
121 instructions: { question: '只讀 `requirement` 這一句、不看任何其他資料,能否判斷一段程式變更是否滿足它?' },
122 criteria: {
123 true: '寫明具體對象(檔名、函式、數值或行為),可直接比對',
124 false: '需要句外脈絡才知道指什麼(如「剛才、上述、這個」),或描述模糊到無法判定',
125 },
126 },
127 }
128 if (req.verify_by === 'test') {
129 questions.testable = {
130 type: 'noul',
131 instructions: { question: '能否只根據 `requirement` 寫出一個測試:一組明確輸入與一個預期輸出?' },
132 criteria: {
133 true: '寫明輸入或情境,以及單一預期結果',
134 false: '缺輸入、缺預期結果,或需要多個斷言',
135 },
136 }
137 }
138 return { model: 'jev-latest', state: { requirement: req.text, verify_by: req.verify_by }, questions }
139}
140
141/** 解析 API 回應;缺欄位、非數字或超出 [0,1] 回 null。 */
142export function parseItemAnswer(text: string, verifyBy: VerifyBy): ItemAnswer | null {
143 try {
144 const answers = (JSON.parse(text) as { answers?: Record<string, { noul?: unknown }> } | null)?.answers
145 if (!answers || typeof answers !== 'object') return null
146 const get = (id: string): number | null => {
147 const v = answers[id]?.noul
148 return typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1 ? v : null
149 }
150 const single = get('single_condition')
151 const self = get('self_contained')
152 if (single === null || self === null) return null
153 const out: ItemAnswer = { single_condition: single, self_contained: self }
154 if (verifyBy === 'test') {
155 const t = get('testable')
156 if (t === null) return null
157 out.testable = t
158 }
159 return out
160 } catch {
161 return null
162 }
163}
164
165/** 任一題 ≤LOW → fail;否則任一題落在不確定帶 → unsure;否則 pass。 */
166export function judgeItem(a: ItemAnswer): Verdict {
167 const vs = [a.single_condition, a.self_contained]
168 if (a.testable !== undefined) vs.push(a.testable)
169 if (vs.some(v => v <= LOW)) return 'fail'
170 if (vs.some(v => v < HIGH)) return 'unsure'
171 return 'pass'
172}
173
174/** 輪數:同一 agent 再送 → round+1;換 agent → revision+1、round 重算。 */
175export function roundState(prev: Checks | undefined, agentKey: string): { round: number; revision: number; decision: 'write' | 'write-final' | 'reject'; askAllowed: boolean } {
176 let round = 1
177 let revision = 1
178 if (prev) {
179 if (prev.agent === agentKey) {
180 round = prev.round + 1
181 revision = prev.revision
182 } else {
183 revision = prev.revision + 1
184 }
185 }
186 // round 超過 3,或 revision 超過硬上限 3(換 agent 後變 4 以上)一律拒絕
187 const decision = round >= 4 || revision >= 4 ? 'reject' : round === 3 ? 'write-final' : 'write'
188 return { round, revision, decision, askAllowed: revision < 3 }
189}
190
191/** 回給 planner/主 session 的報告:待問題目 → fail → unsure → 第 2 層標記,最後附指示句。 */
192export function formatReport(spec: Spec, state: { decision: 'write' | 'write-final'; askAllowed: boolean }): string {
193 const { checks } = spec
194 const text = new Map(spec.requirements.map(r => [r.id, r.text]))
195 const lines: string[] = [
196 `規格 ${spec.id}(第 ${checks.round} 輪,revision ${checks.revision},共 ${spec.requirements.length} 條)`,
197 ]
198
199 if (spec.open_questions.length) {
200 lines.push('', '待問題目:')
201 for (const q of spec.open_questions) {
202 lines.push(`- ${q.req_id}:${q.question}(選項:${q.options.join(' / ')};建議:${q.options[q.recommended]};理由:${q.why})`)
203 }
204 }
205 const items = checks.layer3.items
206 for (const [verdict, title] of [['fail', '未通過(fail)'], ['unsure', '不確定(unsure)']] as const) {
207 const ids = Object.keys(items).filter(id => items[id]!.verdict === verdict)
208 if (!ids.length) continue
209 lines.push('', `${title}:`)
210 for (const id of ids) {
211 const a = items[id]!
212 const scores = [`single=${a.single_condition}`, `self=${a.self_contained}`, ...(a.testable !== undefined ? [`testable=${a.testable}`] : [])]
213 lines.push(`- ${id}(${scores.join(', ')})${text.get(id) ?? ''}`)
214 }
215 }
216 const flagged = Object.keys(checks.layer2).filter(id => checks.layer2[id]!.length)
217 if (flagged.length) {
218 lines.push('', '第 2 層標記:')
219 for (const id of flagged) lines.push(`- ${id}:${checks.layer2[id]!.join('、')}`)
220 }
221
222 const s = checks.layer3.status
223 if (s === 'partial-timeout') lines.push('', '第 3 層部分逾時:未回來的條目只有第 2 層結果')
224 else if (s !== 'ok') lines.push('', `第 3 層未執行:${s}`)
225
226 lines.push('', `剩餘自動重寫 ${Math.max(0, 3 - checks.round)} 輪;revision ${checks.revision}/3`)
227 // 刻意設計:「全部通過」只看已判定的條目;第 3 層未完整時(未執行/部分逾時)報告另有專行說明,不在此排除
228 // 每個 fail/unsure 條目都已列入 open_questions 時,宣告全部通過(planner 據此結束)
229 const asked = new Set(spec.open_questions.map(q => q.req_id))
230 if (Object.entries(items).every(([id, a]) => a.verdict === 'pass' || asked.has(id))) lines.push('全部通過:沒有待重寫的條目')
231 if (state.decision === 'write-final') lines.push('已達重寫上限:停止修改,回報 spec_id 與未通過條目')
232 else lines.push('寫法問題自行重寫後帶 spec_id 再送出;需求問題列入 open_questions 並保留原條目')
233 if (!state.askAllowed) lines.push('不再提問,剩下的問題留在 open_questions,直接交 confirm')
234 return lines.join('\n')
235}
236
237/** 目前生效的規格:status 為 confirmed 中 confirmed_at 最新者,同時間取 id 字典序較大者。 */
238export function pickActiveSpec(files: { name: string; text: string }[]): Spec | null {
239 let best: Spec | null = null
240 for (const f of files) {
241 let spec: Spec
242 try {
243 spec = JSON.parse(f.text) as Spec
244 } catch {
245 continue
246 }
247 if (!isObj(spec) || spec.schema !== 1 || spec.status !== 'confirmed') continue
248 if (!best) {
249 best = spec
250 continue
251 }
252 const a = spec.confirmed_at ?? ''
253 const b = best.confirmed_at ?? ''
254 if (a > b || (a === b && String(spec.id) > String(best.id))) best = spec
255 }
256 return best
257}
258
259/** `YYYYMMDD-HHMM-<slug>`(UTC);slug 清理為 [a-z0-9-],撞名加 -2、-3。 */
260export function makeSpecId(now: Date, slug: string, existing: Set<string>): string {
261 const p = (n: number) => String(n).padStart(2, '0')
262 const stamp = `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}-${p(now.getUTCHours())}${p(now.getUTCMinutes())}`
263 const clean = slug.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 40).replace(/-$/, '') || 'spec'
264 const base = `${stamp}-${clean}`
265 if (!existing.has(base)) return base
266 for (let n = 2; ; n++) if (!existing.has(`${base}-${n}`)) return `${base}-${n}`
267}
268
269/** 僅 `<toplevel>/context/specs/<name>.json`(第一層、無 `..`)為真。 */
270export function isGuardedSpecPath(realPath: string, toplevel: string): boolean {
271 // macOS 預設不分大小寫,`Context/Specs/x.json` 指向同一檔,故前綴不分大小寫比對
272 const prefix = `${toplevel.replace(/\/+$/, '')}/context/specs/`.toLowerCase()
273 if (realPath.split('/').includes('..') || !realPath.toLowerCase().startsWith(prefix)) return false
274 const rest = realPath.slice(prefix.length)
275 if (rest.includes('/') || rest.includes('..')) return false
276 return rest.endsWith('.json') && rest.length > '.json'.length
277}
278hooks/prompts.ts 50 lines1// PLANNER_PROMPT:system1:planner 的 system prompt(由 register.ts 以 $.agent.register 註冊);SPEC_TAIL:附加在 grilling 後的收尾流程。
2export const PLANNER_PROMPT = `你是 planner。輸入是一份需求簡報(本意、限制與決定、已知檔案、不做的事)。你的產出是一份規格檔:一句 intent,加上一組原子條目。你只經 \`mcp__system1__submit_plan\` 寫入規格;簡報之外的決定一律交給使用者。
3
4## 原子條目
5
6每個條目(requirements 的一筆)同時滿足三性質:
7- atomic:只描述一個條件,一次檢查即可判定。行為與修改範圍是兩個條件,各自成條。
8- self-contained:只讀這一句就能判斷一段程式變更是否滿足它。寫明具體對象(檔名、函式、數值、行為),以實名取代「剛才、上述、這個」。
9- observable:寫明如何觀察到它成立,對應 verify_by。
10
11verify_by 依序判斷,取第一個成立的:
121. \`test\`:能寫成一個測試。寫法:一組明確輸入加單一預期輸出。
132. \`jev\`:只看這次變更的 git diff 就能判斷真偽(修改範圍、新增或刪除的檔案與符號、命名、文字內容)。寫法:寫成可判真假的陳述句,寫明比對對象(檔名、符號名、字串)。
143. \`agent\`:需要執行程式,或追蹤跨檔邏輯才能判斷。寫法:寫明要檢查的檔案或行為,以及通過的樣子。
15
16範例。簡報句子「空輸入時回傳空陣列並且只改 extract.ts」含兩個條件、且修改範圍與行為混在一起,拆成:
17- R1 \`test\`:\`extract("")\` 回傳 \`[]\`
18- R2 \`jev\`:變更只涉及 \`extract.ts\`
19
20## 步驟
21
221. 讀相關程式碼:用 Read 讀檔,用 Bash 做唯讀搜尋(如 \`rg\`、\`git grep\`、\`git ls-files\`、\`ls\`);Bash 只用於讀取與搜尋。完成條件:簡報提到的每個檔案與符號都已讀過,條目要用到的檔名、函式名、現況行為都已查證。
232. 寫 intent(一句話本意)與 slug(英文小寫與連字號,概括這份規格)。完成條件:intent 與簡報的本意逐字對得上。
243. 寫條目與 out_of_scope。完成條件:簡報每條限制與決定至少對應一個條目,簡報「不做的事」全部列入 out_of_scope,每個條目都滿足三性質。條目 id 用 R1、R2…。
254. 呼叫 \`mcp__system1__submit_plan\`,帶 slug、intent、requirements、out_of_scope、open_questions。重派時簡報會附 spec_id,一併帶上。完成條件:收到回應。
265. 依回應處理每個被點名的條目,分兩類:
27 - 寫法問題:修正所需資訊已在簡報或程式碼中(拆開「且」連接的兩半、補檔名、換掉指代詞、補 test 的輸入與預期、拆過長條目)。自己重寫,必要時再查程式碼,再回到步驟 4(帶回應中的 spec_id)。
28 - 需求問題:修正需要一個簡報與程式碼都沒有答案的決定(數值、邊界行為、兩種解讀擇一、是否納入範圍)。保留原條目,寫進 open_questions,欄位:\`req_id\`(條目 id)、\`question\`、\`options\`(2–4 個互斥選項)、\`recommended\`(建議選項的索引,從 0 起算)、\`why\`(建議理由)。
29 - 回應含「不再提問」:只處理寫法問題(自行重寫後再送出);需求問題保留在原條目,open_questions 維持現有內容。
306. 回報。完成條件:回報包含 spec_id、仍未通過的條目(id 與原因)、open_questions 全文,以及 submit_plan 最後一次回應的完整報告摘要與其中出現的所有關鍵字(全部通過、已達重寫上限、不再提問,逐字轉述),讓主 session 不依賴 hook 也能判斷下一步,包括是否還要提問。
31
32## 結束條件
33
34submit_plan 回應出現以下任一種時,進入步驟 6 並結束:
35- 「全部通過」
36- 「已達重寫上限」`
37
38// 以兩個換行開頭,接在 grilling skill 文字之後。
39export const SPEC_TAIL = `
40
41## 規格拆題(system1 mod)
42
43frontier 清空、使用者確認已有共識之後,依序做以下五步:
44
451. 問使用者是否產出拆題檔,選項:產出(Recommended)、不用。選「不用」即結束,不做後續步驟。完成條件:使用者已選。
462. 寫需求簡報,內容:本意、限制與決定(附使用者原話)、已知檔案、不做的事。完成條件:每條限制都對應到本次對話的出處,簡報脫離對話也讀得懂。
473. 派 \`system1:planner\`,prompt 為簡報全文;重派時附上 spec_id。完成條件:planner 回報 spec_id。
484. 依 planner 回報(含轉述的關鍵字與報告摘要;hook 附上的摘要若有,以它為準)判斷。planner 回報 open_questions 時,依本 session 的 grilling 提問方式問使用者,把答案併入簡報,回到第 3 步(帶同一 spec_id)。完成條件:最新一次回報沒有 open_questions,或 planner 回應已「不再提問」。
495. 依 planner 回報的報告摘要(hook 附上的摘要若有,以它為準),按待問題目、fail、unsure、第 2 層標記、其餘的順序列出全部條目,呼叫 \`mcp__system1__confirm_plan\`。完成條件:confirm_plan 已回傳結果。回報規格路徑;實作從使用者下一個指令開始。`
50