feat/sdlc-analyze-feasibility/main #23
@@ -0,0 +1,74 @@
|
||||
name: sdlc-analyze
|
||||
description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可行性檢查,把疑點逐題問到共識並輸出摘要。
|
||||
|
||||
# sdlc-analyze
|
||||
|
||||
對一顆需求議題執行可行性檢查,把疑點一題一題問到雙方有共識。
|
||||
|
||||
這一段到共識摘要為止,**不寫入 Gitea**。把工作包開出去是下一段的事。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
|
||||
## 輸入
|
||||
|
||||
一個需求議題編號。
|
||||
|
||||
## 步驟
|
||||
|
||||
### 1. 讀議題
|
||||
|
||||
```
|
||||
node scripts/issue-extract.js --repo <owner/name> --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 或專案看板。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 架構可行性檢查
|
||||
|
||||
問的是「這件事該不該在這裡做、做了會不會把結構弄壞」。
|
||||
|
||||
## 檢查項
|
||||
|
||||
1. **落點** — 這個需求該由哪一個 repo 承接?若需求議題的「影響範圍」列了多個 repo,
|
||||
哪一個是主要落點、其餘各自要改什麼?
|
||||
2. **放錯地方的徵兆** — 若照目前的落點做,是否需要把原本屬於別處的知識搬進來?
|
||||
需不需要讀別的 repo 的資料表或內部模組?
|
||||
3. **相依方向** — 新增的相依是誰依賴誰?會不會造成循環相依(A 依賴 B,B 又回頭依賴 A)?
|
||||
4. **既有邊界** — 這次改動會不會穿過既有的分層或模組邊界?若會,是邊界本來就畫錯,
|
||||
還是這次該繞過?
|
||||
5. **對外介面** — 會不會改變既有的對外介面?既有呼叫端有誰、要不要同時改?
|
||||
6. **可回復性** — 做錯了要怎麼退回?是可以直接 revert,還是會留下已遷移的資料或已發布的介面?
|
||||
|
||||
## 問題怎麼問
|
||||
|
||||
每一條檢查若在議題裡找不到答案,就轉成一個問題。問題要具體到能用一句話回答,
|
||||
不要問「架構上有什麼考量嗎」這種無法收斂的問法。
|
||||
@@ -0,0 +1,19 @@
|
||||
# 資料可行性檢查
|
||||
|
||||
問的是「資料層面會不會出事,以及出事有多難救」。
|
||||
|
||||
## 檢查項
|
||||
|
||||
1. **schema 變更** — 要不要動資料表?新增欄位、改型別、改索引,各自影響哪些既有查詢?
|
||||
2. **遷移** — 既有資料怎麼辦?需不需要回填?回填期間新舊邏輯會不會同時在跑?
|
||||
3. **可逆性** — 遷移能不能退回?若不能,上線前要準備什麼(備份、灰度、開關)?
|
||||
4. **交易邊界** — 一次操作要寫幾個地方?其中一個失敗會不會留下不一致的狀態?
|
||||
哪些必須在同一個交易內?
|
||||
5. **資料量與成長** — 現在的量級是多少、一年後呢?查詢會不會隨資料成長而變慢?
|
||||
6. **敏感資料** — 會不會經手個資或憑證?誰能讀到?日誌裡會不會留下不該留的東西?
|
||||
7. **資料來源** — 資料從哪裡來、由誰維護?來源不可用時這個功能該怎麼表現?
|
||||
|
||||
## 問題怎麼問
|
||||
|
||||
schema 與遷移的答案通常不在需求議題裡,而在資料負責人腦子裡——這一組問題幾乎一定要問。
|
||||
把「不動 schema」也當成一個明確的答案記下來,不要當成沒問。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 邏輯可行性檢查
|
||||
|
||||
問的是「這件事是不是已經有人做過、或者根本不必做」。
|
||||
|
||||
## 檢查項
|
||||
|
||||
1. **重複** — 既有功能裡有沒有已經在做同一件事的?若有,是要沿用、擴充,還是取代?
|
||||
2. **相近但不同** — 有沒有看起來很像但語意不同的既有功能?兩者的差別是什麼、
|
||||
會不會讓使用者混淆?
|
||||
3. **真正的需求** — 使用者描述的是解法還是問題?若是解法,背後要解的問題是什麼?
|
||||
有沒有更直接的做法?
|
||||
4. **邊界情境** — 空值、極大量、並行操作、重複執行,各自該怎麼表現?
|
||||
需求議題的驗收標準有沒有把這些寫進去?
|
||||
5. **失敗時的行為** — 這件事做到一半失敗會怎樣?要回滾、要留下半成品,還是要能續跑?
|
||||
6. **誰會消費** — 產出的東西給誰用?那個消費者現在是怎麼取得同樣資訊的?
|
||||
|
||||
## 問題怎麼問
|
||||
|
||||
「既有功能已經做過同一件事」是最值得先問的一條——它能整個取消這次工作。
|
||||
先查再問:能在程式碼裡查證的就不要拿去問使用者。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 時程可行性檢查
|
||||
|
||||
問的是「要多久、哪一段最可能爆炸」。
|
||||
|
||||
## 檢查項
|
||||
|
||||
1. **相依鏈最長路徑** — 把工作依相依關係排開,最長的那一條有多長?
|
||||
那條路徑上的每一項都非做不可嗎?
|
||||
|
||||
這個階段還沒有工作包,所以要先在腦中拉一份**暫定拆法**:把需求切成幾塊、
|
||||
標出誰卡誰。這份拆法不必精確,但要講得出來——它同時是下一段開工作包的草稿,
|
||||
也是估算的依據。拆不出來本身就是一個要問使用者的問題。
|
||||
2. **未知數最大的一項** — 哪一項的估算最沒把握?它為什麼沒把握——是不熟的技術、
|
||||
不明的既有程式碼,還是等別人回覆?
|
||||
3. **可平行的部分** — 哪些工作彼此沒有相依、可以同時進行?
|
||||
4. **外部相依** — 有沒有卡在別的團隊、別的服務、或需要權限開通的事?
|
||||
那些事的前置時間是多久?
|
||||
5. **可切分性** — 這個需求能不能先交付一部分就對使用者有價值?
|
||||
若能,第一刀切在哪裡?
|
||||
6. **驗證成本** — 做完要怎麼驗?驗證本身要花多少時間(需要造資料、需要別人配合)?
|
||||
|
||||
## 問題怎麼問
|
||||
|
||||
先問「未知數最大的一項」,因為它決定整體估算的可信度。
|
||||
把每一項的人天估算記下來,之後 sdlc-report 會拿它跟實際工時比對。
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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'), /還沒有工作包/);
|
||||
});
|
||||
@@ -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('正本交代了三種輸入都要能吃', () => {
|
||||
|
||||
Reference in New Issue
Block a user