diff --git a/prompts/sdlc-analyze.md b/prompts/sdlc-analyze.md new file mode 100644 index 0000000..5ae2695 --- /dev/null +++ b/prompts/sdlc-analyze.md @@ -0,0 +1,74 @@ +name: sdlc-analyze +description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可行性檢查,把疑點逐題問到共識並輸出摘要。 + +# sdlc-analyze + +對一顆需求議題執行可行性檢查,把疑點一題一題問到雙方有共識。 + +這一段到共識摘要為止,**不寫入 Gitea**。把工作包開出去是下一段的事。 + +這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。 + +## 輸入 + +一個需求議題編號。 + +## 步驟 + +### 1. 讀議題 + +``` +node scripts/issue-extract.js --repo --index <編號> +``` + +拿到的是結構化欄位,不必再讀整份議題全文。 + +**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被 +整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,建議先執行 `/sdlc-sync` +把它們整併回描述,再回來做分析。使用者堅持要繼續就繼續,但要記下這件事, +並在共識摘要裡註明「分析基於未整併留言前的描述」。 + +### 2. 對四份清單列出疑點 + +依序讀這四份規則正本,逐條對照議題內容: + +1. `references/feasibility-architecture.md` — 架構:放錯 repo、循環相依、穿越邊界。 +2. `references/feasibility-logic.md` — 邏輯:既有功能是不是已經做過同一件事。 +3. `references/feasibility-data.md` — 資料:schema 變更、遷移、交易邊界。 +4. `references/feasibility-schedule.md` — 時程:相依鏈最長路徑、未知數最大的一項。 + +每一條檢查若在議題裡找不到答案,就轉成一個問題。**能在程式碼裡查證的就自己去查, +不要拿去問使用者**——把問題留給只有人能回答的事。 + +### 3. 逐題問到共識 + +**一次問一題。** 問完等使用者回答,再問下一題,讓他能看著前一題的答案回答下一題。 +不要一次丟出五個問題,也不要把多個問題包成一題的多個選項。 + +順序固定為**架構 → 邏輯 → 資料 → 時程**,前一類的問題全部清空才進入下一類。 +前面的答案常常會讓後面的問題消失或改寫;每問完一題,重新檢視剩下的問題還成不成立。 + +每一題固定給兩個選項: + +- **建議** — 你的答案,附上理由。理由要寫「為什麼是這個」,不是複述問題。 +- **手動輸入** — 讓使用者自己寫。任何一題都必須能手動作答,不被選項限制。 + +問題本身要具體到能用一句話回答。問不出收斂答案的問題,多半是問題本身太大,拆開再問。 + +### 4. 輸出共識摘要 + +全部問完後,輸出一份摘要讓使用者做最後確認,內容包含: + +- **每一類的結論** — 架構/邏輯/資料/時程各自問出了什麼,逐條列出「問題 → 答案」。 +- **改變了什麼** — 分析過程中翻掉或修正了需求議題裡的哪些假設。 +- **仍然未決的事** — 問了但沒有答案、或使用者明確說「之後再說」的事。 +- **人天估算** — 每一項的估算與最沒把握的那一項。 + +摘要只印在終端,**不寫回議題、不建立任何東西**。使用者看過點頭之後,才進入下一段。 + +## 邊界 + +- 不對 Gitea 產生任何寫入:不建議題、不改描述、不貼標籤、不留留言。 +- 不修改使用者的專案檔案。查證既有功能時只讀不寫。 +- 不替使用者決定他沒回答的事。問不到答案就進「仍然未決的事」。 +- 不自行建立標籤、Milestone 或專案看板。 diff --git a/references/feasibility-architecture.md b/references/feasibility-architecture.md new file mode 100644 index 0000000..b9a593f --- /dev/null +++ b/references/feasibility-architecture.md @@ -0,0 +1,20 @@ +# 架構可行性檢查 + +問的是「這件事該不該在這裡做、做了會不會把結構弄壞」。 + +## 檢查項 + +1. **落點** — 這個需求該由哪一個 repo 承接?若需求議題的「影響範圍」列了多個 repo, + 哪一個是主要落點、其餘各自要改什麼? +2. **放錯地方的徵兆** — 若照目前的落點做,是否需要把原本屬於別處的知識搬進來? + 需不需要讀別的 repo 的資料表或內部模組? +3. **相依方向** — 新增的相依是誰依賴誰?會不會造成循環相依(A 依賴 B,B 又回頭依賴 A)? +4. **既有邊界** — 這次改動會不會穿過既有的分層或模組邊界?若會,是邊界本來就畫錯, + 還是這次該繞過? +5. **對外介面** — 會不會改變既有的對外介面?既有呼叫端有誰、要不要同時改? +6. **可回復性** — 做錯了要怎麼退回?是可以直接 revert,還是會留下已遷移的資料或已發布的介面? + +## 問題怎麼問 + +每一條檢查若在議題裡找不到答案,就轉成一個問題。問題要具體到能用一句話回答, +不要問「架構上有什麼考量嗎」這種無法收斂的問法。 diff --git a/references/feasibility-data.md b/references/feasibility-data.md new file mode 100644 index 0000000..0a77b70 --- /dev/null +++ b/references/feasibility-data.md @@ -0,0 +1,19 @@ +# 資料可行性檢查 + +問的是「資料層面會不會出事,以及出事有多難救」。 + +## 檢查項 + +1. **schema 變更** — 要不要動資料表?新增欄位、改型別、改索引,各自影響哪些既有查詢? +2. **遷移** — 既有資料怎麼辦?需不需要回填?回填期間新舊邏輯會不會同時在跑? +3. **可逆性** — 遷移能不能退回?若不能,上線前要準備什麼(備份、灰度、開關)? +4. **交易邊界** — 一次操作要寫幾個地方?其中一個失敗會不會留下不一致的狀態? + 哪些必須在同一個交易內? +5. **資料量與成長** — 現在的量級是多少、一年後呢?查詢會不會隨資料成長而變慢? +6. **敏感資料** — 會不會經手個資或憑證?誰能讀到?日誌裡會不會留下不該留的東西? +7. **資料來源** — 資料從哪裡來、由誰維護?來源不可用時這個功能該怎麼表現? + +## 問題怎麼問 + +schema 與遷移的答案通常不在需求議題裡,而在資料負責人腦子裡——這一組問題幾乎一定要問。 +把「不動 schema」也當成一個明確的答案記下來,不要當成沒問。 diff --git a/references/feasibility-logic.md b/references/feasibility-logic.md new file mode 100644 index 0000000..98fc5fc --- /dev/null +++ b/references/feasibility-logic.md @@ -0,0 +1,20 @@ +# 邏輯可行性檢查 + +問的是「這件事是不是已經有人做過、或者根本不必做」。 + +## 檢查項 + +1. **重複** — 既有功能裡有沒有已經在做同一件事的?若有,是要沿用、擴充,還是取代? +2. **相近但不同** — 有沒有看起來很像但語意不同的既有功能?兩者的差別是什麼、 + 會不會讓使用者混淆? +3. **真正的需求** — 使用者描述的是解法還是問題?若是解法,背後要解的問題是什麼? + 有沒有更直接的做法? +4. **邊界情境** — 空值、極大量、並行操作、重複執行,各自該怎麼表現? + 需求議題的驗收標準有沒有把這些寫進去? +5. **失敗時的行為** — 這件事做到一半失敗會怎樣?要回滾、要留下半成品,還是要能續跑? +6. **誰會消費** — 產出的東西給誰用?那個消費者現在是怎麼取得同樣資訊的? + +## 問題怎麼問 + +「既有功能已經做過同一件事」是最值得先問的一條——它能整個取消這次工作。 +先查再問:能在程式碼裡查證的就不要拿去問使用者。 diff --git a/references/feasibility-schedule.md b/references/feasibility-schedule.md new file mode 100644 index 0000000..2dc0d79 --- /dev/null +++ b/references/feasibility-schedule.md @@ -0,0 +1,25 @@ +# 時程可行性檢查 + +問的是「要多久、哪一段最可能爆炸」。 + +## 檢查項 + +1. **相依鏈最長路徑** — 把工作依相依關係排開,最長的那一條有多長? + 那條路徑上的每一項都非做不可嗎? + + 這個階段還沒有工作包,所以要先在腦中拉一份**暫定拆法**:把需求切成幾塊、 + 標出誰卡誰。這份拆法不必精確,但要講得出來——它同時是下一段開工作包的草稿, + 也是估算的依據。拆不出來本身就是一個要問使用者的問題。 +2. **未知數最大的一項** — 哪一項的估算最沒把握?它為什麼沒把握——是不熟的技術、 + 不明的既有程式碼,還是等別人回覆? +3. **可平行的部分** — 哪些工作彼此沒有相依、可以同時進行? +4. **外部相依** — 有沒有卡在別的團隊、別的服務、或需要權限開通的事? + 那些事的前置時間是多久? +5. **可切分性** — 這個需求能不能先交付一部分就對使用者有價值? + 若能,第一刀切在哪裡? +6. **驗證成本** — 做完要怎麼驗?驗證本身要花多少時間(需要造資料、需要別人配合)? + +## 問題怎麼問 + +先問「未知數最大的一項」,因為它決定整體估算的可信度。 +把每一項的人天估算記下來,之後 sdlc-report 會拿它跟實際工時比對。 diff --git a/test/helpers/prompt-doc.js b/test/helpers/prompt-doc.js new file mode 100644 index 0000000..292bd54 --- /dev/null +++ b/test/helpers/prompt-doc.js @@ -0,0 +1,62 @@ +/** + * 流程正本與規則正本的共用檢查。 + * + * 這些檔案是文件不是程式,但它們是各指令實際交付的東西:正本的平台中立性決定 + * 轉接檔能不能一份寫到底,段落結構決定下游解析得到什麼。靠人記不牢,用測試釘住。 + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { repoRoot } from './run-script.js'; + +/** 讀一份流程正本 */ +export function readPrompt(name) { + return readFileSync(join(repoRoot, 'prompts', `${name}.md`), 'utf8'); +} + +/** 讀一份規則正本 */ +export function readReference(name) { + return readFileSync(join(repoRoot, 'references', `${name}.md`), 'utf8'); +} + +/** 讀一份輸出模板 */ +export function readTemplate(name) { + return readFileSync(join(repoRoot, 'templates', `${name}.md`), 'utf8'); +} + +/** + * 正本裡不該出現的字樣:任何一家助理的工具名、目錄名或呼叫語法。 + * 出現任何一個,就代表這份正本已經綁死在某個平台上。 + */ +export const PLATFORM_SPECIFIC = [ + 'AskUserQuestion', + 'Claude', + 'Codex', + 'Antigravity', + 'Copilot', + 'Kiro', + 'OpenCode', + 'oh-my-pi', + '.claude', + '.codex', +]; + +/** + * 斷言一份正本是平台中立的,且 description 帶上指定前綴。 + * @param {string} prompt 正本內容 + * @param {string} command 指令名,例如 sdlc-plan + */ +export function assertNeutralPrompt(prompt, command) { + const description = prompt.match(/^description:\s*(.+)$/m)?.[1]; + assert.ok(description, '正本需要一行 description 供轉接檔取用'); + assert.ok( + description.startsWith(`僅由 /${command} 指令叫用。`), + `description 前綴不符:${description}`, + ); + + assert.equal(prompt.startsWith('---'), false, '正本不該有 YAML frontmatter,那是轉接檔的事'); + + for (const token of [...PLATFORM_SPECIFIC, `$${command}`]) { + assert.equal(prompt.includes(token), false, `正本不該出現平台專屬字樣:${token}`); + } +} diff --git a/test/sdlc-analyze-assets.test.js b/test/sdlc-analyze-assets.test.js new file mode 100644 index 0000000..7e69ef2 --- /dev/null +++ b/test/sdlc-analyze-assets.test.js @@ -0,0 +1,105 @@ +/** + * sdlc-analyze 的交付物:四份可行性檢查清單與流程正本。 + * 這一段不寫入 Gitea,所以沒有腳本——交付的就是這些文件本身。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { assertNeutralPrompt, readPrompt, readReference } from './helpers/prompt-doc.js'; + +const prompt = readPrompt('sdlc-analyze'); + +/** 四類檢查,順序即提問順序 */ +const CHECKS = [ + { kind: '架構', file: 'feasibility-architecture' }, + { kind: '邏輯', file: 'feasibility-logic' }, + { kind: '資料', file: 'feasibility-data' }, + { kind: '時程', file: 'feasibility-schedule' }, +]; + +// ── 規則正本 ─────────────────────────────────────────────────────── + +test('四份可行性檢查清單各自存在且有檢查項', () => { + for (const { kind, file } of CHECKS) { + const reference = readReference(file); + const items = [...reference.matchAll(/^\d+\.\s+\*\*(.+?)\*\*/gm)]; + assert.ok(items.length >= 4, `${kind}清單至少要有四條檢查項,目前 ${items.length} 條`); + } +}); + +test('每份清單都交代了「問題怎麼問」,不只列檢查項', () => { + for (const { kind, file } of CHECKS) { + assert.match(readReference(file), /## 問題怎麼問/, `${kind}清單缺少提問指引`); + } +}); + +test('各清單涵蓋議題點名的重點', () => { + assert.match(readReference('feasibility-architecture'), /循環相依/); + assert.match(readReference('feasibility-logic'), /既有功能/); + assert.match(readReference('feasibility-data'), /schema/); + assert.match(readReference('feasibility-data'), /遷移/); + assert.match(readReference('feasibility-data'), /交易邊界/); + assert.match(readReference('feasibility-schedule'), /最長路徑/); + assert.match(readReference('feasibility-schedule'), /未知數最大/); +}); + +// ── 流程正本 ─────────────────────────────────────────────────────── + +test('正本平台中立,description 前綴正確', () => { + assertNeutralPrompt(prompt, 'sdlc-analyze'); +}); + +test('正本逐一指名四份規則正本,且順序為架構→邏輯→資料→時程', () => { + const positions = CHECKS.map(({ file }) => prompt.indexOf(`references/${file}.md`)); + assert.equal(positions.every((p) => p >= 0), true, '四份清單都要被正本指名讀取'); + assert.deepEqual([...positions].sort((a, b) => a - b), positions, '指名順序需為架構→邏輯→資料→時程'); +}); + +test('正本明令一次只問一題', () => { + assert.match(prompt, /一次問一題/); + assert.match(prompt, /不要一次丟出/); +}); + +test('正本規定每題固定兩個選項:建議(含理由)與手動輸入', () => { + assert.match(prompt, /\*\*建議\*\*/); + assert.match(prompt, /理由/); + assert.match(prompt, /\*\*手動輸入\*\*/); + assert.match(prompt, /不被選項限制/); +}); + +test('正本要求前一類問完才進下一類', () => { + assert.match(prompt, /前一類的問題全部清空才進入下一類/); +}); + +test('正本規定最後輸出共識摘要,且摘要只印不寫', () => { + assert.match(prompt, /共識摘要/); + assert.match(prompt, /只印在終端/); + assert.match(prompt, /不寫回議題/); +}); + +test('正本把「不對 Gitea 寫入」寫成明確邊界', () => { + const boundary = prompt.slice(prompt.indexOf('## 邊界')); + assert.match(boundary, /不對 Gitea 產生任何寫入/); + assert.match(boundary, /不建議題/); + assert.match(boundary, /不留留言/); +}); + +test('正本要求先看未處理留言數,不是 0 就提示先整併', () => { + assert.match(prompt, /未處理留言數/); + assert.match(prompt, /sdlc-sync/); + assert.match(prompt, /先停下來|先整併/); +}); + +test('正本指名由 issue-extract 讀議題,而不是自己讀全文', () => { + assert.match(prompt, /issue-extract/); + assert.match(prompt, /不必再讀整份議題全文/); +}); + +test('正本要求能自己查證的就不要拿去問使用者', () => { + assert.match(prompt, /能在程式碼裡查證的就自己去查/); +}); + +test('時程清單交代了「這階段還沒有工作包」該怎麼估', () => { + // 相依鏈最長路徑預設了一份拆法,而分析階段還沒有工作包可依 + assert.match(readReference('feasibility-schedule'), /暫定拆法/); + assert.match(readReference('feasibility-schedule'), /還沒有工作包/); +}); diff --git a/test/sdlc-plan-assets.test.js b/test/sdlc-plan-assets.test.js index f281f55..c3add9f 100644 --- a/test/sdlc-plan-assets.test.js +++ b/test/sdlc-plan-assets.test.js @@ -7,12 +7,10 @@ */ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { repoRoot } from './helpers/run-script.js'; +import { assertNeutralPrompt, readPrompt, readTemplate } from './helpers/prompt-doc.js'; -const template = readFileSync(join(repoRoot, 'templates', 'requirement-issue.md'), 'utf8'); -const prompt = readFileSync(join(repoRoot, 'prompts', 'sdlc-plan.md'), 'utf8'); +const template = readTemplate('requirement-issue'); +const prompt = readPrompt('sdlc-plan'); /** 需求議題的九個段落,順序即議題裡的順序 */ const SECTIONS = [ @@ -50,35 +48,8 @@ test('總覽段落預留了總覽網頁的連結佔位', () => { // ── 流程正本 ─────────────────────────────────────────────────────── -test('正本的 description 以「僅由 /sdlc-plan 指令叫用。」起頭', () => { - const description = prompt.match(/^description:\s*(.+)$/m)?.[1]; - assert.ok(description, '正本需要一行 description 供轉接檔取用'); - assert.ok( - description.startsWith('僅由 /sdlc-plan 指令叫用。'), - `description 前綴不符:${description}`, - ); -}); - -test('正本是平台中立的:不出現任何特定助理的語法或名稱', () => { - const platformSpecific = [ - 'AskUserQuestion', - 'Claude', - 'Codex', - 'Antigravity', - 'Copilot', - 'Kiro', - 'OpenCode', - 'oh-my-pi', - '.claude', - '$sdlc-plan', - ]; - for (const token of platformSpecific) { - assert.equal(prompt.includes(token), false, `正本不該出現平台專屬字樣:${token}`); - } -}); - -test('正本沒有 YAML frontmatter:那是各平台轉接檔的事', () => { - assert.equal(prompt.startsWith('---'), false); +test('正本平台中立,description 前綴正確', () => { + assertNeutralPrompt(prompt, 'sdlc-plan'); }); test('正本交代了三種輸入都要能吃', () => {