feat/sdlc-analyze-feasibility/main #23

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