SLOPSHOPPER

system1

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

newguardstatusprompttoolprocess
v0.2.0MITupdated 2026-10-13cch-agent/claude-mods/system1
A shopper browsing a rack in a slop shop
README

cch

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,再依上方步驟重裝。

Source 3 files
hooks/register.ts 534 lines
1import 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}
534
hooks/plan.ts 278 lines
1// 第 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}
278
hooks/prompts.ts 50 lines
1// 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