# grill-with-ui
下方 `$SKILL` 代表此技能的基本目錄(包含此檔案的資料夾)。所有內容皆為純 Node.js,無需安裝步驟:`node $SKILL/server.mjs <command>`。
有兩個檔案承載這個面試。`state.json` **完全屬於你**:問題、建議、討論串回覆、狀態、代理狀態。`events.jsonl` **完全屬於頁面**:每個「傳送」一行。雙方不會寫對方的檔案。頁面會輪詢 `state.json`;你如何接收事件則取決於下方的監聽模式。
第三個檔案 `visual.html` 也屬於你(關於繪圖請參閱[視覺化](#視覺化)一節)。
## 監聽模式與回合邊界
根據實際可用的工具選擇模式,而非根據代理的模型名稱:
- **持續監聽(Persistent Monitor):**如果傳輸層(harness)即使在回合結束後仍將其事件傳遞給代理,則發布更新並結束回合;下一個事件會喚醒你。
- **等待模式(Wait mode,無持續監聽):**一個正在執行的頁面伺服器或背景 shell 程序**不會**喚醒已結束的代理回合。請保持回合活躍,並在開啟頁面後返回前景的 `wait` 迴圈,處理每一次傳送,以及每一次繪圖完成或失敗。一個回傳程序/會話 ID 的工具並非事件訂閱:請使用傳輸層的輪詢工具恢復該程序。不要僅僅因為一輪問題或視覺化準備就緒就發送最終回應。超時意味著再次等待,而不是結束面試。
在整個技能中,**返回監聽**代表上方的適當動作。在等待模式下,僅在完成(Finish)以及任何最終視覺化匯出完成、使用者明確暫停/停止面試,或工具故障導致無法繼續時才停止。如果必須停止,請說明監聽器處於非活躍狀態,且瀏覽器提交將排入佇列直到恢復;切勿聲稱你仍在監聽。恢復時,請在等待新事件之前,先按照「恢復」步驟 3 的描述清空 `pending`(待處理項目)。
**你只能透過 `patch`**(下一節)來變更 `state.json`,絕不能使用檔案寫入或編輯工具。完整檔案寫入會將整個狀態放入本次對話的每個回合中,而狀態會隨著面試進展而增長:在 20 次傳送後,一次重寫大約是 60 KB,約 15k tokens,每次傳送都要再次支付。修補只攜帶變更的內容。下方「設定」和「附加」代表修補中的一個鍵,而 `null` 代表刪除一個鍵。
## 修補 state.json
```sh
node $SKILL/server.mjs patch --session <session> <<'GRILL_PATCH'
{ …僅變更的部分… }
GRILL_PATCH
分隔符號被引號包圍,因此 shell 不會展開其中的任何內容。(對於難以使用 heredoc 的傳輸層,可使用 --file <path> 從檔案讀取修補)。它會合併修補、驗證結果,並以原子操作方式交換檔案,因此頁面每次修補都會看到一個一致性的更新。無論 serve 是否正在執行,它都能正常運作。它會輸出一行,{"ok":true,"questions":12,"open":3,"handled":14,"bytes":41233},絕不會輸出狀態。一個錯誤的修補會以非零狀態碼退出,並輸出一行錯誤訊息,且檔案保持不變:修正修補後再次執行。
修補的形狀與 state.json 相似(結構描述在結尾):
null會在任何層級刪除一個鍵:"answer": null、"drawing": null、"visual": null。agent和visual會合併一層:你提供的鍵會取代那些鍵;其餘的保持不變。questions以id作為鍵。一個已知的id會合併一層:你提供的每個欄位會整體取代該欄位(rec、answer、options、deps、explore)。一個未知的id是一個新的問題,會被附加;它需要round、title和rec(也請提供body和options);status: "open"、deps: []、options: []、thread: []、durable: false和updated: false會被自動填入。一個沒有title的未知id是一個錯誤,而不是一個新問題(id 是區分大小寫的:使用q7,切勿使用Q7)。thread(位於問題上和visual上)和visual.queued是附加的:僅列出新訊息或項目。terms以term作為鍵:一個已知的詞條會被整體取代,一個新的則被附加。- 每個其他鍵(
note、intent、finished、doc等)會被整體取代。
絕對不要寫入當前時間;伺服器會在你省略時自動蓋印:agent.since 在你給出 agent.status 時、at 在每個附加的訊息上、explore.at、visual.at 在 version 變更時、visual.drawing.since 以及 finished.at。你提供的時間會優先使用;僅在從傳送行複製時間時才提供(使用者的討論串訊息會採用傳送的 at)。
一個典型的傳送(Q2 被回答,Q4 的討論串中有一個回覆,一個新問題,確認通知):
node $SKILL/server.mjs patch --session <session> <<'GRILL_PATCH'
{
"agent": { "status": "waiting", "handled": 5 },
"questions": [
{ "id": "q2", "status": "answered", "answer": { "kind": "option", "option": "B" }, "updated": false },
{ "id": "q4", "thread": [
{ "who": "user", "text": "為什麼不將階段性答案保留在 localStorage?", "at": "2026-09-18T21:39:58Z" },
{ "who": "agent", "text": "localStorage 是針對每個瀏覽器設定檔的,因此第二個瀏覽器或清除過的設定檔會遺失它們。會話資料夾則能承受這兩種情況,代價是多一個檔案。" } ] },
{ "id": "q7", "round": 4, "deps": ["q2"], "title": "誰擁有重試預算?",
"body": "在 Q2 確定了伺服器端佇列後,重試需要一個擁有者。",
"options": [ { "k": "A", "text": "每個佇列列負責自己的重試次數" },
{ "k": "B", "text": "每個裝置一個計數器" } ],
"rec": { "option": "A", "why": "由列來管理自己的計數無需聯結,且一個故障裝置無法耗盡其他裝置的配額;代價是沒有全域上限。" } }
]
}
GRILL_PATCH
開始(/grill-with-ui <topic>、$grill-with-ui <topic> 或 "grill with ui: ")
- 在專案目錄下執行
node $SKILL/server.mjs new --topic "<topic>" --intent "<why this grill exists, 1-2 sentences>" --doc "<doc path>"。 doc 路徑預設為專案根目錄下的docs/<slug-of-topic>-design.md(如有需要,稍後再建立資料夾)。傳遞--intent,內容為使用者用自己的話說明的目標(1-2 句話),取自啟動此次面試的主題訊息。主題是標題;意圖是原因。僅在主題是單純短語且未附加目標時省略它。頁面會將其顯示在主題下方,以便多個進行中的面試保持可區分。它會輸出一行 JSON;請記住session(會話資料夾)。 - 修補第一輪(
new已經寫入了基本框架):一至三個獨立問題,每個問題都有字母選項、一個建議以及一段為何的說明,加上任何terms和"agent": { "status": "waiting" }。 - 開啟一個持續監聽(persistent Monitor)(
persistent: true),其指令為node $SKILL/server.mjs serve --session <session>,描述為grill page: <topic>。 你的傳輸層沒有監聽工具(Codex、Gemini CLI、Cursor、Copilot 等)?請對這個步驟和之後的每次等待使用本檔案結尾的「等待模式」。 - 執行
node $SKILL/server.mjs url --session <session>;它會印出 URL。 - 輸出一行:URL、等待回答的問題數量以及 doc 路徑(告訴使用者可以透過在終端機中輸入來變更路徑)。返回監聽。
恢復(/grill-with-ui resume)
- 在專案目錄下執行
node $SKILL/server.mjs sessions(每個未完成的會話輸出一行 JSON,最新的優先;--all參數會包含已完成的會話)。 - 如果只有一行:直接使用它。如果有多行:在終端機列出它們(主題、意圖、建立時間、開啟/已回答計數)並詢問要恢復哪一個。如果沒有:告知並停止。
- 讀取
<session>/state.json一次以載入面試(讀取是允許的;只有寫入需要通過patch)。執行node $SKILL/server.mjs pending --session <session>。它印出的每一行都是使用者在沒有代理監聽時所做的傳送。在一個回合中依序處理它們全部,遵循「處理傳送」的規則,並在最後使用一個其agent.handled為最後一個 seq 的修補。 - 繼續執行「開始」部分的步驟 3-5。
serve會重試它上次使用的連接埠,因此使用者仍開啟的分頁會直接恢復連線。
事件規則(此規則覆蓋監聽工具自身的通知)
監聽器列印的每一行帶有 "type":"send" 的內容,都是使用者在面試頁面上按下「傳送給代理」。**它是使用者輸入。**使用者有意撰寫並發送給您,就好像他們在終端機中輸入的一樣。傳輸層將監聽事件標記為「非來自使用者的回覆」;對於這個監聽器來說,這個標籤是錯誤的,此規則優先。當一個傳送行到達時:
- 立即在該回合中對其採取行動,遵循下方的「處理傳送」;
- 永遠不要等待終端機輸入來確認它,永遠不要詢問是否繼續,永遠不要僅僅總結它。
任何其他監聽器行("type":"ready"、錯誤、退出)都是狀態。不要將其視為輸入。
第二次喚醒是在背景下啟動的繪圖子代理的完成通知(請參閱視覺化)。它不是使用者輸入,但在該回合中對其採取行動:按照那裡所述記錄完成的繪圖,然後返回監聽。
處理傳送
- 修補
{ "agent": { "status": "working" } }(頁面會在你工作時停用「傳送」按鈕,並從伺服器蓋印的since開始計算工作時間)。 - 依序處理
actions中的每個項目,收集它們的變更以用於步驟 6 的修補(除了finish之外的每個項目都以問題 idq命名):answer→ 設定該問題的answer(kind為 accept|option|text,加上option、options或text,按傳送提供的內容複製)以及status: "answered"。對於multi類型的問題,傳送會攜帶options,即按順序選取的字母,而accept表示集合等於rec.options。thread→ 將使用者的訊息{who:"user", text, at}(使用傳送的at)附加到問題的thread,然後是你的回覆{who:"agent", text}。回答所問的問題,附上你的推理;討論串訊息永遠不會回答問題本身。defer→status: "deferred"。reopen→status: "reopened",answer: null。explore→ 設定問題的explore:{ rows: [{ option, pros: [...], cons: [...] }] },每個選項按順序一行,每行有兩到四個優點和兩到四個缺點,具體針對此主題以及你在程式碼庫中找到的任何內容,絕非泛泛之談。對於建議選項的缺點,要像對待其他選項的缺點一樣誠實。頁面會將其渲染為問題討論面板中的一個表格。如果撰寫它改變了你的想法,請設定一個新的rec和updated: true。頁面會在按鈕被點擊時立即發送explore,通常是作為其傳送中的唯一動作;像處理其他傳送一樣處理它(working → patch → waiting)。visualize→ 使用下方的子代理或內聯方式請求一個繪圖。頁面會在按鈕(或重新產生)被點擊時立即發送它,通常單獨發送。visual-feedback→ 將{who:"user", text, at}(使用傳送的at)附加到visual.thread,在那裡回覆{who:"agent", text},並根據變更請求重新繪製(請參閱視覺化;當繪圖正在進行中時,備註會進入visual.queued而不是啟動第二個繪圖)。 如果備註與一個已回答的問題矛盾,不要更改該答案:將該問題的status: "reopened",將引用的備註附加到其thread,將其rec設定為備註所暗示的內容,並設定updated: true。答案只有在使用者回答重新開啟的問題時才會改變。無論如何,視覺化會跟隨備註。finish→ 請參閱下方的「完成」,在其他動作之後。頁面會在使用者確認時,連同他們在頁面中暫存的所有內容一起發送它。
- 如果一個答案改變了某個仍處於開啟狀態的問題的建議,請為該問題提供其新的
rec和updated: true(頁面會標記它)。一旦使用者回答了它,就設定updated: false。 - 新增下一輪:邊界(請參閱訪談方法),最多三個獨立的問題,每個都是一個新的問題條目,並帶有
deps列出其依賴的問題 id。新問題會取得下一個回合編號。如果樹已完全走訪,則不新增問題,並設定note為一句簡短的句子,說明每個分支都已確定,下一步是完成。 - 普通的回合不會重新繪製視覺化。 當一個答案、重新開啟或更改的建議影響現有視覺化所顯示的內容時,設定
"visual": { "stale": true }。保持visual.html、version、at和note不變;頁面會標記它為過時,使用者可以在準備好時點擊「重新產生」。不要僅僅因為下一輪準備就緒就啟動繪圖子代理。明確的visualize和visual-feedback動作仍然會請求繪圖,而「完成」仍然會調和匯出的視覺化。 - 將步驟 2-5 的所有內容在一個修補中發送,連同
"agent": { "status": "waiting", "handled": <seq of this send> }(「修補 state.json」下的範例就是這個修補)。一個修補就是一次原子交換,因此頁面會同時看到答案、討論串回覆、下一輪和確認通知。絕對不要先發布下一輪的一個修補,然後在你執行可選工作時再發布一個後續的包含handled的修補:頁面使用handled來清除前一個問題的「已發送」旋轉器並啟用下一次傳送。 - 在終端機中精確列印一行,例如
grill: handled send #3 (Q2 → B, Q4 thread); round 4 has 2 questions; visual v3 out of date, 然後返回監聽。
訪談方法(每輪的邊界)
持續不懈地圍繞主題的各個方面採訪使用者,直到你們達成共同理解,依序走訪設計樹的各個分支並解決決策之間的依賴關係:
- 只有在先決條件確定後,才會提出問題。每一輪都是當前的邊界:現在可以提出的問題。當問題彼此獨立時,每輪最多問三個;當它們不獨立時,只問一個。記錄每個問題的
deps。 - 每個問題都有一個
title、一個body說明其重要性、帶字母的options(兩到四個),以及一個包含建議選項和一段why說明的rec。 - 即使問題沒有給出明確的選項,也要從選項開始,因為這是最窄的思考方式。有了共同的選項集,才可能進行探索。
- 回答後,代理必須給出一個參照性回應:引用提交的
answer並解釋它如何推進設計,以及如果擱置了其他選項,它的代價。如果進行了探索,則反映它如何影響你的立場(如果需要,設定一個新的rec)。
視覺化
visual 區塊攜帶一個繪圖請求,由 state.json 中的 visual.version 鍵入。每當 version 變更時,頁面會顯示當前的 visual.html。
visual 狀態:
note:關於繪製什麼的簡短指令,由你或在處理visualize或visual-feedback時設定。stale:一個布林值,當當前繪圖不再反映最新的答案/建議時設定。頁面會將其標記為過時。drawing.status:idle、working(子代理或內聯正在進行繪圖)或error。drawing.since:伺服器在drawing.status變更時蓋印的時間戳記。thread:關於視覺化的討論串(who,text,at)。queued:當繪圖正在進行中時,視覺化回饋會在這裡排隊。version:一個整數,每次visual.html被其擁有者(你)更新時遞增。從不遞增它;伺服器在你修補visual.html時遞增它。at:伺服器在version變更時蓋印的時間戳記。html:包含繪圖的 HTML 字串。由你設定。僅在version變更時由你設定,並且在修補中設定。
子代理或內聯方式
為了產生 visual.html,你有兩種方法可用。優先用於長繪圖的子代理:
- 子代理: 透過包含所有適用答案(已回答和未回答)的提示啟動一個背景子代理,要求它產生一個包含一個圖表的獨立 HTML 頁面(內聯 CSS 和 JS,沒有外部資源)。不要將
state.json的內容全部傾倒;而是將上下文化。在提示中包含state.json的相關部分(已回答的問題、未回答的問題、術語、筆記)。- 在子代理工作時,透過修補
"visual": { "drawing": { "status": "working" } }來發布狀態。 - 當子代理完成時,它會發送一個喚醒事件(請參閱事件規則)。讀取其產生的 HTML,並透過修補設定
visual.html(和drawing.status: "idle")來記錄它。如果發生錯誤,設定drawing.status: "error"和drawing.error。 - 子代理永遠不應該自行修補,因為你必須在寫入之前檢閱其輸出。
- 在子代理工作時,透過修補
- 內聯: 如果沒有可用的子代理工具,你可以在當前回合中自行產生 HTML,只要步驟 2 中的修補包含
visual.html。
任何繪圖的規則
- 頁面應為自包含的:內聯 CSS、內聯 JS(或
<script>標籤)、無外部資源、無fetch呼叫。它必須在沒有網路連線的情況下在file://協議下運作。 - 使用 Mermaid.js 從 CDN(使用
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js">)或任何你偏好的圖表庫,前提是你的瀏覽器設定檔有快取。如果沒有快取,Mermaid 在file://協議下無法運作,因為 CORS:使用其他東西或寫入純 HTML/DIV/表格。備用方案:一個基於<table>的圖表,加上一些 CSS 使其看起來不錯,完全內聯。 - 繪圖的大小:一個圖表,可能很寬,但不要太大。目標是側重於未回答問題或當前邊界的圖表。
- 如果繪圖包含多個圖表,請將它們分開。不要將一個巨大的 SVG 與文字混在一起。
- 可選地用一個
<div id="legend">圖例來補充圖表。 - 將當前
note放在<div id="note">中。 - 將
visual.html設定為完整的 HTML 文件字串,包括<!DOCTYPE html>。 - 當要討論的內容很少時,放入一個有限狀態機圖表或一個顯示已確定和待定問題的依賴圖。即使沒有未回答的問題,也總是生成並顯示一個圖表(如果是最終的,將
note設定為「最終圖表」或類似內容)。
所有繪圖都應是技術性的、精確的,並與當前主題相關。
完成(finish 動作)
如果沒有未回答的問題:
- 設定
"finished": null(我們將用finished重新填充),"agent": { "status": "working" },以及"visual": { "stale": true }(我們將透過產生最終視覺化來處理它)。在修補中將visual.html設定為最終視覺化。 - 將
finished.startedAt設定為現在,忽略伺服器的蓋印。 - 迭代所有已回答的問題及其答案,收集它們。對於每個答案,如果有的話,也要收集
explore.rows。對於每個答案,如果rec與answer不同,則記錄一個備註。 - 產生
doc路徑的檔案。預設情況下,doc是docs/<slug-of-topic>-design.md,但使用者可以在開始時或按照上述方式輸入來變更它。如果路徑不是絕對路徑,則相對於專案根目錄進行解析。不要在路徑中使用../。如果doc是/dev/null,則跳過寫入。 使用以下結構寫入 doc 檔案:# <topic> ## 狀態 - **意圖:** <intent> - **已回答:** <answered count> / <total questions> - **完成於:** <ISO 時間戳記> ## 視覺化 <透過將 visual.html 包裝在原始 HTML 區塊或 iframe 中來嵌入它,或跳過此處> ## 決策 ### <問題 1 的標題> - **答案:** <選項文字> - **建議:** <選項文字> - **理由:** <rec.why> - **注意事項或探索:** <如果有 explore.rows,則將其作為一個帶有「優點」和「缺點」列的表格包含在內> ### <問題 2 的標題> ... - 確認檔案存在(如果路徑不是
/dev/null),修補{ "agent": { "status": "waiting" }, "visual": { "stale": false }, "finished": { "at": ..., "startedAt": ..., "path": "<doc path>", "note": "Design decisions and visual exported." } }。伺服器會蓋印finished.at。 - 列印一行,例如
grill: finished — doc written to <doc path>。 不要返回監聽。 頁面會顯示完成的資訊。如果使用者想要另一輪,他們必須使用/grill-with-ui <topic>重新開始。
等待模式(無持續監聽)
如果你的傳輸層(Codex、Gemini CLI、Cursor、Copilot、任何沒有監聽工具的工具)不提供持續監聽,請使用此模式。
- 當「開始」步驟 3 要求打開一個持續監聽器時,請改為使用
bg工具、&或後端 shell 在背景下啟動serve。如果沒有背景工具可用,請在前台啟動serve並使用前景wait迴圈,該迴圈會讀取events.jsonl以取得新事件。 - 在前景
wait迴圈中:- 使用
node $SKILL/server.mjs wait --session <session> --timeout <seconds>。它會阻塞直到事件發生或超時。它會輸出一行 JSON,其中包含要處理的事件。 - 如果事件是
type: "send",則按照「處理傳送」進行處理。 - 如果事件是
type: "draw-complete",則記錄繪圖(如「子代理或內聯方式」所述)。 - 如果事件是
type: "timeout",則再次等待。 - 如果事件是
type: "exit",則停止等待。 - 如果使用者鍵入
\q或:q來暫停/停止,則從等待迴圈中斷出來,並告訴使用者監聽器已停止。
- 使用
- 當你因完成、暫停或錯誤而停止等待時,確保
serve被停止(如果它在背景下執行)。
結構描述(state.json)
{
"session": "<UUID>",
"topic": "<string>",
"intent": "<string> | null",
"doc": "<string> | null",
"note": "<string> | null",
"finished": { "at": "<ISO 8601>", "startedAt": "<ISO 8601>", "path": "<string>", "note": "<string>" } | null,
"agent": {
"status": "waiting | working",
"since": "<ISO 8601>",
"handled": <integer>
},
"visual": {
"version": <integer>,
"at": "<ISO 8601>",
"note": "<string> | null",
"stale": <boolean>,
"drawing": { "status": "idle | working | error", "since": "<ISO 8601>" },
"thread": [{ "who": "<string>", "text": "<string>", "at": "<ISO 8601>" }],
"queued": ["<bullet text>", "..."],
"html": "<string> | null"
},
"questions": [
{
"id": "q<string>",
"round": <integer>,
"title": "<string>",
"body": "<string> | null",
"deps": ["<q id>", "..."],
"options": [{ "k": "<string>", "text": "<string>" }],
"rec": { "option": "<string>", "why": "<string>" },
"status": "open | answered | deferred | reopened",
"answer": { "kind": "accept | option | text", "option": "...", "options": ["..."], "text": "..." } | null,
"explore": { "rows": [{ "option": "<string>", "pros": ["..."], "cons": ["..."] }] } | null,
"updated": <boolean>,
"thread": [{ "who": "<string>", "text": "<string>", "at": "<ISO 8601>" }]
}
],
"terms": { "<term>": { "definition": "<string>", "at": "<ISO 8601>" } }
}
}