Merge pull request 'feat/work-package-issues/main' (#24) from feat/work-package-issues/main into master

Reviewed-on: #24
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #24.
This commit is contained in:
2026-09-17 05:46:19 +00:00
8 changed files with 315 additions and 17 deletions
+85 -6
View File
@@ -1,11 +1,12 @@
name: sdlc-analyze
description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可行性檢查,把疑點逐題問到共識並輸出摘要。
description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可行性檢查,逐題問到共識後產生工作包議題。
# sdlc-analyze
對一顆需求議題執行可行性檢查,把疑點一題一題問到雙方有共識。
對一顆需求議題執行可行性檢查,把疑點一題一題問到雙方有共識,再把共識變成一批工作包議題。
這一段到共識摘要為止,**不寫入 Gitea**。把工作包開出去是下一段的事。
分成兩段:**可行性分析**到共識摘要為止,完全不寫入 Gitea;使用者看過摘要點頭之後,
才進入**產生工作包**,那一段才會建立議題。
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
@@ -13,7 +14,7 @@ description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可
一個需求議題編號。
## 步驟
## 第一段:可行性分析
### 1. 讀議題
@@ -66,9 +67,87 @@ node scripts/issue-extract.js --repo <owner/name> --index <編號>
摘要只印在終端,**不寫回議題、不建立任何東西**。使用者看過點頭之後,才進入下一段。
## 第二段:產生工作包
**使用者對共識摘要點頭之後才開始。** 摘要沒有經過確認就不要往下走。
### 5. 切出工作包
把需求切成幾顆工作包。一顆工作包是**開發者拿了就能動手、做完有明確結果**的單位:
它有自己的驗收標準,做完能單獨被檢視,不必等別的工作包一起才看得出成果。
切的依據是第一段問出來的共識,特別是時程清單那份暫定拆法——那本來就是這一段的草稿。
**標題格式為「{動詞}{名詞}」**,例如「建立工作包的抽取契約」、「產生圖解版總覽網頁」。
**禁止流水編號與任何無意義代號**(`WP-01`、`任務三`、`第一階段`):命名本身就要說明用途,
看標題就知道這顆在做什麼,不必點進去。
### 6. 組出每顆工作包的內容
套用 `templates/work-package-issue.md`,依序填滿九個段落:
1. **這個工作包在做什麼** — 一句話。讓人掃過標題與這一行就決定要不要點進來。
2. **描述** — 從使用者的角度說這顆做完之後什麼事變得可能,不要寫成逐層的實作清單。
3. **架構圖** — 見下方「架構圖的限制」。
4. **範圍邊界** — 明列**不做什麼**。這一段的用途是抵抗範圍蔓延,寫得越具體越有用。
5. **介面契約** — 表格,四欄:介面/產出者/消費者/形狀。讓人知道自己產出的東西誰會消費。
這顆不產出對外介面就寫一列「無」,不要留空表。
6. **待辦** — 巢狀結構:每一項待辦底下掛**它自己的**驗收標準,讓人知道這一項做到什麼程度算完成。
```
- [ ] 建立共用函式庫
- [ ] 具名 flag 解析可拒絕未知參數
- [ ] 單行 JSON 輸出格式固定
- [ ] 加上前置檢查
- [ ] 四層各自回傳可區分的錯誤碼
```
上層是待辦、縮排一層是該項的驗收,不要再往下巢狀。兩者都用 checkbox,實作時會被逐項勾選。
7. **整體驗收** — 整顆工作包做完才驗得出來的事,與個別待辦的驗收不重複。
8. **repo 列表** — 這顆會動到哪些 repo。
9. **關聯** — 至少要有一行 `需求議題:#<編號>` 指回來源。阻擋、先決與人天估算由後續流程補上。
### 7. 先試跑,再寫入
每顆工作包各寫一個暫存檔,然後逐顆:
```
node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file <暫存檔> \
--labels "<標籤>" --dry-run
```
`--dry-run` 會印出將送出的請求、把標籤名稱換成 id,並在標題已存在時如實顯示「實跑會是
no-op」。確認無誤後拿掉該旗標再跑一次。
標籤一樣只能從 `scripts/labels-list.js` 回傳的既有標籤裡挑,**不得自行建立新標籤**。
中斷後重跑不會產生重複工作包:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆
並把 `created` 設為 `false`。
### 8. 回報
列出每顆工作包的編號、標題與網址。不要把議題內容再貼一次。
## 架構圖的限制
依工作包的性質選圖:
- **`sequenceDiagram`** — 重點在「誰呼叫誰、順序為何」時用。
- **`flowchart`** — 重點在「條件分支與資料流向」時用。
- **`stateDiagram-v2`** — 重點在「狀態怎麼轉移」時用。
節點數上限 **12**,每個節點的文字上限 **8 字**。超過就拆成多張圖,或者乾脆不畫。
模板的 `{{架構圖}}` 要填入**完整的內容**,兩種形式擇一:
- 要畫:一個或多個完整的 ```mermaid 圍欄區塊。
- 不畫:**只在超過上限拆不開、或畫了不會比文字更清楚時**才選這個,填一行說明為什麼不畫,**不要加圍欄**。
## 邊界
- 不對 Gitea 產生任何寫入:不建議題、不改描述、不貼標籤、不留留言。
- **共識摘要之前不對 Gitea 產生任何寫入**:不建議題、不改描述、不貼標籤、不留留言。
- 第二段只建立工作包議題。不建相依、不掛 Milestone、不加看板、不寫人天估算——那是後續流程的事。
- 不自行建立標籤、Milestone 或專案看板。
- 不修改使用者的專案檔案。查證既有功能時只讀不寫。
- 不替使用者決定他沒回答的事。問不到答案就進「仍然未決的事」。
- 不自行建立標籤、Milestone 或專案看板。
- 不關閉或刪除任何既有議題。
+1 -1
View File
@@ -84,7 +84,7 @@ node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file
模板的 `{{流程圖}}` 要填入**完整的內容**,兩種形式擇一:
- 要畫:一個或多個完整的 ```mermaid 圍欄區塊。
- 不畫:一行說明為什麼不畫(例如「流程為單一直線,畫圖無助理解」),**不要加圍欄**。
- 不畫:**只在超過上限拆不開、或畫了不會比文字更清楚時**才選這個,填一行說明為什麼不畫(例如「流程為單一直線,畫圖無助理解」),**不要加圍欄**。
圍欄寫在填入的內容裡而不是模板裡,否則不畫圖時會留下一個空的 mermaid 區塊,
在議題頁上是一塊渲染失敗的紅字。
+4
View File
@@ -102,6 +102,10 @@ export function listSection(sections, name) {
/**
* 取出兩欄表格型段落。以分隔列(|---|---|)為界,之後才是資料列;
* 沒有分隔列就當成沒有資料,避免把表頭當成一筆名詞。
*
* 只處理兩欄:多出來的欄會被丟掉。目前唯一的使用者是需求議題的領域名詞表。
* 工作包的介面契約是四欄(介面/產出者/消費者/形狀),wp-extract 需要另一個
* 保留全部欄位的版本,不能直接沿用這一支。
* @param {Map<string, string>} sections
* @param {string} name
* @returns {{term: string, def: string}[]}
+37
View File
@@ -0,0 +1,37 @@
## 這個工作包在做什麼
{{一句話}}
## 描述
{{描述}}
## 架構圖
{{架構圖}}
## 範圍邊界
{{範圍邊界}}
## 介面契約
| 介面 | 產出者 | 消費者 | 形狀 |
| --- | --- | --- | --- |
{{介面契約}}
## 待辦
{{待辦}}
## 整體驗收
{{整體驗收}}
## repo 列表
{{repo列表}}
## 關聯
{{關聯}}
+30
View File
@@ -60,3 +60,33 @@ export function assertNeutralPrompt(prompt, command) {
assert.equal(prompt.includes(token), false, `正本不該出現平台專屬字樣:${token}`);
}
}
/**
* 斷言模板的 `## 標題` 就是這組段落,順序一致。
* 段落順序即下游抽取契約的解析依據,兩邊必須一起改。
*/
export function assertTemplateSections(template, sections) {
const headings = [...template.matchAll(/^## (.+)$/gm)].map((m) => m[1].trim());
assert.deepEqual(headings, sections);
}
/**
* 斷言正本的編號清單逐一交代了這組段落,順序與模板一致。
* @param {string} text 正本內容,或其中相關的一段
*/
export function assertPromptListsSections(text, sections) {
const listed = [...text.matchAll(/^\d+\.\s+\*\*(.+?)\*\*/gm)].map((m) => m[1].trim());
assert.deepEqual(listed, sections);
}
/**
* 斷言圖表段落只有佔位、沒有寫死的 mermaid 圍欄。
* 正本允許「乾脆不畫」,圍欄寫死在模板裡的話,不畫時會在議題頁留下一塊渲染失敗的空區塊。
* @param {string} heading 圖表段落的標題,例如 '流程圖'
* @param {string} nextHeading 其後一個段落的標題,用來框出範圍
*/
export function assertDiagramPlaceholderOnly(template, heading, nextHeading) {
assert.equal(template.includes('```mermaid'), false);
const section = template.slice(template.indexOf(`## ${heading}`), template.indexOf(`## ${nextHeading}`));
assert.match(section.trim(), new RegExp(`^## ${heading}\\s+\\{\\{${heading}\\}\\}$`));
}
+9 -2
View File
@@ -76,13 +76,20 @@ test('正本規定最後輸出共識摘要,且摘要只印不寫', () => {
assert.match(prompt, /不寫回議題/);
});
test('正本把「不對 Gitea 寫入」寫成明確邊界', () => {
test('正本把「共識摘要之前不寫入」寫成明確邊界', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不對 Gitea 產生任何寫入/);
assert.match(boundary, /共識摘要之前不對 Gitea 產生任何寫入/);
assert.match(boundary, /不建議題/);
assert.match(boundary, /不留留言/);
});
test('第一段在共識摘要之前不叫用任何寫入型腳本', () => {
const analysis = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 第二段'));
for (const writer of ['issue-create', 'issue-update', 'issue-link', 'project-add', 'timer']) {
assert.equal(analysis.includes(writer), false, `第一段不該出現寫入型腳本 ${writer}`);
}
});
test('正本要求先看未處理留言數,不是 0 就提示先整併', () => {
assert.match(prompt, /未處理留言數/);
assert.match(prompt, /sdlc-sync/);
+11 -8
View File
@@ -7,7 +7,14 @@
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertNeutralPrompt, readPrompt, readTemplate } from './helpers/prompt-doc.js';
import {
assertDiagramPlaceholderOnly,
assertNeutralPrompt,
assertPromptListsSections,
assertTemplateSections,
readPrompt,
readTemplate,
} from './helpers/prompt-doc.js';
const template = readTemplate('requirement-issue');
const prompt = readPrompt('sdlc-plan');
@@ -28,8 +35,7 @@ const SECTIONS = [
// ── 輸出模板 ───────────────────────────────────────────────────────
test('模板依序包含九個段落', () => {
const headings = [...template.matchAll(/^## (.+)$/gm)].map((m) => m[1].trim());
assert.deepEqual(headings, SECTIONS);
assertTemplateSections(template, SECTIONS);
});
test('模板以 {{變數}} 佔位,不留任何空白待填欄位', () => {
@@ -82,14 +88,11 @@ test('正本指名由 issue-create 寫入,並提醒先以 --dry-run 檢查', (
test('正本逐一交代九個段落,且順序與模板一致', () => {
// 只看「組出議題內容」那份編號清單,不看散落在行文裡的提及
const listed = [...prompt.matchAll(/^\d+\.\s+\*\*(.+?)\*\*/gm)].map((m) => m[1].trim());
assert.deepEqual(listed, SECTIONS);
assertPromptListsSections(prompt, SECTIONS);
});
test('模板不把 mermaid 圍欄寫死:不畫圖時才不會留下渲染失敗的空區塊', () => {
assert.equal(template.includes('```mermaid'), false);
const section = template.slice(template.indexOf('## 流程圖'), template.indexOf('## 驗收標準'));
assert.match(section.trim(), /^## 流程圖\s+\{\{流程圖\}\}$/);
assertDiagramPlaceholderOnly(template, '流程圖', '驗收標準');
});
test('正本交代了畫與不畫兩種情況各該填什麼', () => {
+138
View File
@@ -0,0 +1,138 @@
/**
* 工作包議題的模板,以及正本裡「產生工作包」那一段的規則。
*
* 模板的段落順序決定 #9 的 wp-extract 解析得到什麼;待辦的巢狀寫法決定實作階段
* 勾得到哪一行。這兩件事寫死在測試裡,改動時才會被逼著一起改。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertDiagramPlaceholderOnly,
assertPromptListsSections,
assertTemplateSections,
readPrompt,
readTemplate,
} from './helpers/prompt-doc.js';
const template = readTemplate('work-package-issue');
const prompt = readPrompt('sdlc-analyze');
/** 第二段的內容,避免把第一段的字樣誤認成這一段的規則 */
const phase2 = prompt.slice(prompt.indexOf('## 第二段'));
/** 工作包議題的九個段落,順序即議題裡的順序 */
const SECTIONS = [
'這個工作包在做什麼',
'描述',
'架構圖',
'範圍邊界',
'介面契約',
'待辦',
'整體驗收',
'repo 列表',
'關聯',
];
// ── 輸出模板 ───────────────────────────────────────────────────────
test('模板依序包含九個段落', () => {
assertTemplateSections(template, SECTIONS);
});
test('模板每個段落都有 {{變數}} 佔位', () => {
const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)];
assert.equal(placeholders.length, SECTIONS.length);
});
test('介面契約是四欄表格:介面/產出者/消費者/形狀', () => {
const section = template.slice(template.indexOf('## 介面契約'), template.indexOf('## 待辦'));
assert.match(section, /\|\s*介面\s*\|\s*產出者\s*\|\s*消費者\s*\|\s*形狀\s*\|/);
assert.match(section, /\|\s*---\s*\|/, '要有分隔列,wp-extract 以它為界找資料列');
});
test('模板不把 mermaid 圍欄寫死:不畫圖時才不會留下渲染失敗的空區塊', () => {
assertDiagramPlaceholderOnly(template, '架構圖', '範圍邊界');
});
// ── 產生工作包那一段 ───────────────────────────────────────────────
test('第二段要等使用者對共識摘要點頭才開始', () => {
assert.match(phase2, /點頭之後才開始/);
assert.match(phase2, /沒有經過確認就不要往下走/);
});
test('正本逐一交代九個段落,且順序與模板一致', () => {
assertPromptListsSections(phase2, SECTIONS);
});
test('標題規則為動詞加名詞,且明令禁止流水編號', () => {
assert.match(phase2, /\{動詞\}\{名詞\}/);
assert.match(phase2, /禁止流水編號/);
assert.match(phase2, /WP-01/, '要舉出被禁止的寫法,不要只說「不要用編號」');
});
test('待辦的巢狀寫法有具體範例,且說明上層與縮排各代表什麼', () => {
const example = phase2.match(/```[^\n]*\n([\s\S]*?)```/)?.[1] ?? '';
const indents = example
.split('\n')
.filter((line) => line.trim().startsWith('- ['))
.map((line) => line.match(/^\s*/)[0].length);
assert.ok(indents.length >= 3, '範例要有數行待辦才看得出結構');
assert.ok(
Math.max(...indents) > Math.min(...indents),
'範例要真的有縮排出來的巢狀層,不能只用文字描述',
);
assert.match(phase2, /上層是待辦、縮排一層是該項的驗收/);
assert.match(phase2, /不要再往下巢狀/);
});
test('範圍邊界要求明列不做什麼', () => {
assert.match(phase2, /明列\*\*不做什麼\*\*/);
assert.match(phase2, /抵抗範圍蔓延/);
});
test('介面契約段落交代了沒有對外介面時怎麼填', () => {
assert.match(phase2, /不要留空表/);
});
test('關聯段落必須指回來源需求議題', () => {
assert.match(phase2, /需求議題:#/);
});
test('架構圖依性質三選一,並釘住節點與字數上限', () => {
for (const kind of ['sequenceDiagram', 'flowchart', 'stateDiagram-v2']) {
assert.match(prompt, new RegExp(kind));
}
const limits = prompt.slice(prompt.indexOf('## 架構圖的限制'));
assert.match(limits, /12/);
assert.match(limits, /8\s*字/);
assert.match(limits, /拆成多張圖|不畫/);
});
test('寫入前先試跑,且試跑的價值有被說明', () => {
assert.match(phase2, /--dry-run/);
assert.match(phase2, /no-op/, '要說明試跑會顯示「實跑是 no-op」,否則使用者不知道該看什麼');
});
test('標籤只能從既有標籤挑,且指名用 labels-list 取得', () => {
assert.match(phase2, /labels-list/);
assert.match(phase2, /不得自行建立新標籤/);
});
test('冪等查重有被交代:重跑不會產生第二顆', () => {
assert.match(phase2, /重跑不會產生重複工作包/);
assert.match(phase2, /created.*false|`created` 設為 `false`/);
});
test('第二段明列它「不做」的事,避免搶走後續流程的工作', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不建相依/);
assert.match(boundary, /不掛 Milestone/);
assert.match(boundary, /不加看板/);
assert.match(boundary, /不寫人天估算/);
});
test('「不畫圖」是有條件的退路,不是免死金牌', () => {
const limits = prompt.slice(prompt.indexOf('## 架構圖的限制'));
assert.match(limits, /只在超過上限拆不開、或畫了不會比文字更清楚時/);
});