feat/delegation-markers/main #64

Merged
admin merged 3 commits from feat/delegation-markers/main into master 2026-09-18 01:19:39 +00:00
7 changed files with 351 additions and 15 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
| `prompts/` | 流程正本(`sdlc-{plan,analyze,feat,fix,sync,report}.md`),唯一的事實來源 | 平台中立 markdown,不含任何平台專屬語法 |
| `scripts/` | 所有副作用(Gitea API、git、檔案系統)的唯一出口 | Node、零外部套件,僅用內建 `fetch` / `child_process` / `fs` |
| `templates/` | 所有產出格式(議題、PR、報表、總覽網頁) | 以 `{{變數}}` 佔位,不含邏輯。唯一例外是 `overview-artifact.html`:它是一份要在瀏覽器裡開的網頁,需要一段把 mermaid 圖畫出來的腳本 |
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單) | 由流程正本指名讀取,不自行散落於 prompts |
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單、委派判準) | 由流程正本指名讀取,不自行散落於 prompts |
| `bin/tea-sdlc.js` | 指令入口:取走子指令,其餘 argv 原樣交出去 | 不含任何平台目錄知識,也不自己動手做事 |
| `scripts/install.js` | 平台偵測與轉接檔產生/移除 | 唯一知道各平台目錄結構的地方 |
| `scripts/install-verify.js` | 安裝後走一遍叫用鏈(轉接檔 → PATH 上的 tea-sdlc → 流程正本) | 只認拿到的轉接檔路徑,不自己推導平台目錄;不碰網路 |
+12 -3
View File
@@ -26,6 +26,13 @@ Milestone、看板與人天估算補上。
錶已經跑在同一顆議題上時起錶什麼都不做,所以 plan 接著跑 analyze 不會把累積的時間
切成兩段。**錶不跨階段跑**,也**不碰別顆議題上的錶**。
## 〔可委派〕的意思
標題後綴 `〔可委派〕` 的步驟只在意結果:**你的環境若能把工作交給子代理,就交出去,
只把結果帶回來;不能就自己做。** 沒有這個後綴的步驟一律自己做。
怎麼挑、為什麼這樣挑,見 `references/delegation.md`——判準只有那一份,這裡不複述。
## 第一段:可行性分析
### 1. 讀議題
@@ -59,7 +66,7 @@ node scripts/timer.js --repo <owner/name> --index <需求議題編號> --dry-run
請他自己去停**,不要代勞:那一段時間該記在哪顆議題上只有他知道。順帶說明停錶不會動到
任何既有的工作樹——碼錶只管時間、工作樹只管檔案。
### 3. 對四份清單列出疑點
### 3. 對四份清單列出疑點 〔可委派〕
依序讀這四份規則正本,逐條對照議題內容:
@@ -165,7 +172,7 @@ no-op」。確認無誤後拿掉該旗標再跑一次。
工作包建好之後,把它們之間的關係與時程補上。做完這一段,看板上呈現的才是真實的
開發順序,而不是一堆平鋪的議題。
### 10. 算出截止日
### 10. 算出截止日 〔可委派〕
把每顆工作包的編號、人天估算與先決關係寫成一份計畫檔:
@@ -207,7 +214,7 @@ node scripts/project-add.js --repo <owner/name> --index <編號> --project "<看
看板名稱靠掃最近 50 筆議題反查 id,反查不到就會請你直接貼專案網址(結尾即 id)。
本流程不建立 Milestone,也不建立專案。
### 12. 產生分析版的圖解總覽
### 12. 產生分析版的圖解總覽 〔可委派〕
用同一份 `templates/overview-artifact.html` 再產一份,但這一份要多出**工作包全景**:
把工作包之間的相依與截止日畫成一張圖,讓開發者看得出自己這一項在整體中的位置。
@@ -224,6 +231,8 @@ node scripts/project-add.js --repo <owner/name> --index <編號> --project "<看
節點寫工作包標題與截止日,箭頭方向是「先決 → 後續」。節點一樣以 12 個為上限,
超過就只畫相依鏈最長路徑上的那幾顆,其餘在頁尾列成文字。
委派的是**產出那份 HTML**;拿到網址之後寫回議題那一步**不委派**(判準第四條)。
網址一樣寫回需求議題:
```
+13 -3
View File
@@ -19,6 +19,13 @@ description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、備妥工
一個工作包議題編號。使用者直接給的,或 `/sdlc-fix` 收到議題後交棒過來的——
兩者一樣處理,**不要因為是交棒來的就要求他再打一次指令**。
## 〔可委派〕的意思
標題後綴 `〔可委派〕` 的步驟只在意結果:**你的環境若能把工作交給子代理,就交出去,
只把結果帶回來;不能就自己做。** 沒有這個後綴的步驟一律自己做。
怎麼挑、為什麼這樣挑,見 `references/delegation.md`——判準只有那一份,這裡不複述。
## 第一段:領取與開工準備
### 1. 讀工作包
@@ -83,7 +90,7 @@ node scripts/claim.js --repo <owner/name> --index <編號> --dry-run
不論哪一種,來源分支都必須**已經在遠端上**:工作樹的起點一律取自 `origin/{來源分支}`。
### 4. 把議題標題翻成英文
### 4. 把議題標題翻成英文 〔可委派〕
分支名的中段要用英文,中文會讓 CI 與 URL 出問題。把工作包議題的標題翻成
**小寫英文 kebab、40 字元以內**,例如「建立工作包的抽取契約」→ `wp-extract-contract`。
@@ -231,9 +238,12 @@ reviewer 得從一堆「已完成第 N 項」裡找真正的討論。
## 第三段:提交與開立 PR
### 13. 分批提交
### 13. 分批提交 〔可委派〕
全部待辦都勾完之後才進這一段。變更依類型分批:
全部待辦都勾完之後才進這一段。變更依類型分批。
委派的是**方案計算**:變更分成哪幾批、每一批收哪些檔案、各自的 `--type` 與描述怎麼寫。
**實際提交不委派**——底下那支 `commit-split.js` 由主流程執行(判準第四條)。
```
node scripts/commit-split.js --path <工作樹路徑> --type feat \
+10 -1
View File
@@ -30,6 +30,13 @@ description: 僅由 /sdlc-plan 指令叫用。把一段口語需求轉成結構
起與停都寫在這一份裡,**錶不跨階段跑**:跑完就去開會而錶跑一整天,報表當場失真。
反過來,別顆議題上的錶一律不碰——那一段時間該記在哪顆議題上只有使用者知道。
## 〔可委派〕的意思
標題後綴 `〔可委派〕` 的步驟只在意結果:**你的環境若能把工作交給子代理,就交出去,
只把結果帶回來;不能就自己做。** 沒有這個後綴的步驟一律自己做。
怎麼挑、為什麼這樣挑,見 `references/delegation.md`——判準只有那一份,這裡不複述。
## 步驟
### 1. 記下開始時間
@@ -125,7 +132,7 @@ node scripts/timer.js --repo <owner/name> --index <編號> --dry-run
是哪一顆,請他自己去停**,不要代勞:那一段時間該記在哪顆議題上只有他知道。順帶說明
停錶不會動到任何既有的工作樹——碼錶只管時間、工作樹只管檔案。
### 8. 產生圖解版總覽
### 8. 產生圖解版總覽 〔可委派〕
套用 `templates/overview-artifact.html`,把議題的總覽、目標與流程圖填成一份可以直接投影的
網頁。這一份是給**非技術的利害關係人**看的:他們不必讀完技術細節就知道這件事在做什麼。
@@ -142,6 +149,8 @@ node scripts/timer.js --repo <owner/name> --index <編號> --dry-run
若執行環境能把 HTML 發佈成可分享的網址,就發佈;不能的話存成檔案,把路徑當成網址用。
委派的是**產出那份 HTML**;拿到網址之後寫回議題那一步**不委派**(判準第四條)。
拿到網址後寫回議題:
```
+57
View File
@@ -0,0 +1,57 @@
# 委派判準
流程正本裡標著〔可委派〕的步驟,是照這份判準挑出來的。**四條全部成立才可委派**;
任何一條不成立就不標,那一步一律自己做。
這份是規則正本,流程正本指名讀它,不把規則抄過去——抄過去就會有兩份各自演化的判準。
## 判準
1. **產出是可驗證的成品** — 交回來的東西呼叫端看得出對不對:一個英文 kebab 字串、
一份日期表、一份疑點清單、一份 HTML。說不出「拿回來的該長什麼樣」的步驟不可委派,
因為呼叫端沒有辦法判斷它做完了沒有。
2. **步驟中不會詢問使用者** — **硬排除**。子代理問不到使用者,一旦卡在提問就只能自行
決定,而它決定的那件事使用者從頭到尾不會知道。逐題問到共識、問來源分支、認不出語言
就停下來問——這類步驟一律不標,無論它們看起來多像例行公事。
3. **失敗能被呼叫端偵測** — 交不出東西、交回來的形狀不對,主流程當場看得出來並接手。
失敗只會表現成「結果怪怪的」而不會表現成「失敗」的步驟不可委派。
4. **只產出草稿或唯讀結果,不直接寫入 Gitea 或 git** — **硬排除**。理由不是子代理做不好,
而是**它的失敗沒有人看著**:備妥工作樹失敗會中止整個領取,實際提交失敗會留下半套
git 歷史,這兩種都需要當場有人判斷下一步。
## 怎麼委派
標記寫成標題後綴 `〔可委派〕`,並以**能力描述**說明怎麼做,不指名任何平台的工具:
> 這一步只在意結果;你的環境若能把工作交給子代理,就交出去,只把結果帶回來;不能就自己做。
子代理是平台專屬能力,而流程正本必須保持平台中立。能力描述對不支援的平台是自然降級,
同一份正本兩邊都讀得通,不需要維護兩份。**不要把它改寫成工具名**——那會讓正本綁死在
某一個助理上。
委派與否不改變產出:兩條路的結果必須一樣,差別只在中間產物留不留在主脈絡裡。
## 部分委派
一個步驟裡只有一半合判準時,**標記照下,並在該步寫明哪一半不委派**。這比整步不標好——
不標的話那一半的中間產物照樣塞滿主脈絡;也比整步委派安全,因為第四條是硬排除。
目前有三步是這個形狀:兩份圖解總覽(產出 HTML 可委派,寫回議題的 `issue-update` 不委派)
與分批提交(方案計算可委派,實際跑 `commit-split.js` 不委派)。
## 目前標記為〔可委派〕的步驟
這張表與正本上的標記互為正本,**兩邊由資產測試雙向綁住**。改一邊就要改另一邊,
否則測試會擋下來——沒有這條斷言,兩邊會漂開,而漂開時不會有任何東西報錯。
| 正本 | 步驟 | 委派範圍 |
| --- | --- | --- |
| `sdlc-plan` | 產生圖解版總覽 | 產出 HTML;寫回議題不委派 |
| `sdlc-analyze` | 對四份清單列出疑點 | 全步 |
| `sdlc-analyze` | 算出截止日 | 全步 |
| `sdlc-analyze` | 產生分析版的圖解總覽 | 產出 HTML;寫回議題不委派 |
| `sdlc-feat` | 把議題標題翻成英文 | 全步 |
| `sdlc-feat` | 分批提交 | 方案計算;實際提交不委派 |
`sdlc-sync`、`sdlc-fix` 與 `sdlc-report` 目前沒有可委派的步驟:前兩者每一步都在問使用者
或寫入 Gitea,後者只有一支唯讀腳本,委派出去省不到什麼。
+220
View File
@@ -0,0 +1,220 @@
/**
* 委派:判準正本、正本上的標記,以及兩者之間那條雙向斷言。
*
* 判準與標記分住兩個檔案,而它們講的是同一件事。沒有雙向斷言,兩邊會慢慢漂開——
* 而漂開的時候不會有任何東西報錯:正本上多標一步不會壞,判準表少列一項也不會壞,
* 只是下一個讀的人會以為自己讀到的是全部。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DELEGATABLE,
PLATFORM_SPECIFIC,
promptSteps,
readPrompt,
readReference,
} from './helpers/prompt-doc.js';
const PROMPTS = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat', 'sdlc-fix', 'sdlc-sync', 'sdlc-report'];
const reference = readReference('delegation');
/** 帶標記的三份正本;另外三份目前沒有可委派的步驟 */
const 有標記的正本 = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat'];
/** `正本/步驟` 這種好讀的鍵,比對失敗時看得出差在哪一步 */
const 鍵 = ({ prompt, name }) => `${prompt}/${name}`;
/** delegation.md 那張表列出來的步驟 */
const 表上的 = () =>
[...reference.matchAll(/^\| `(sdlc-[a-z]+)` \| (.+?) \| (.+?) \|$/gm)].map((m) => ({
prompt: m[1],
name: m[2].trim(),
範圍: m[3].trim(),
}));
/** 正本上實際被標記的步驟 */
const 正本上的 = () =>
PROMPTS.flatMap((prompt) =>
promptSteps(readPrompt(prompt))
.filter((step) => step.marked)
.map((step) => ({ prompt, name: step.name, body: step.body })),
);
// ── 判準正本 ───────────────────────────────────────────────────────
test('delegation.md 的開頭形狀與既有規則正本一致', () => {
assert.equal(reference.startsWith('# '), true, '規則正本一律以 H1 起頭,不放 frontmatter');
assert.match(reference.split('\n')[0], /委派/);
});
/** 判準那一節的四條,各自含標題與理由 */
function 判準逐條() {
const 節 = reference.slice(reference.indexOf('## 判準'), reference.indexOf('## 怎麼委派'));
return 節.split(/^(?=\d+\. \*\*)/m).filter((one) => /^\d+\. \*\*/.test(one));
}
test('四條判準逐條載明,一條不多一條不少', () => {
const 判準 = reference.slice(reference.indexOf('## 判準'), reference.indexOf('## 怎麼委派'));
const 條 = 判準逐條().map((one) => /^\d+\. \*\*(.+?)\*\*/.exec(one)[1]);
assert.equal(條.length, 4, `判準應為四條,目前 ${條.length} 條:${條.join('、')}`);
assert.match(判準, /可驗證的成品/);
assert.match(判準, /不會詢問使用者/);
assert.match(判準, /失敗能被呼叫端偵測/);
assert.match(判準, /不直接寫入 Gitea 或 git/);
});
test('第二條與第四條各自標明是硬排除,並各自寫出理由', () => {
// 數「硬排除」出現幾次的話,別處多提一句就會失敗;要問的是「那兩條上面有沒有」
const [, 二, , 四] = 判準逐條();
assert.match(二, /硬排除/, '第二條是硬排除,不是建議');
assert.match(二, /子代理問不到使用者/, '沒寫理由的話,下一個人會把它當成建議而繞過去');
assert.match(四, /硬排除/, '第四條是硬排除,不是建議');
assert.match(四, /失敗沒有人看著/, '理由不是子代理做不好,要寫清楚,否則會被當成不信任');
});
// ── 雙向斷言 ───────────────────────────────────────────────────────
test('正本上被標記的集合,等於 delegation.md 列出的集合', () => {
const 表 = 表上的().map(鍵).sort();
const 正本 = 正本上的().map(鍵).sort();
assert.deepEqual(正本, 表, '改一邊就要改另一邊,否則兩份說法會漂開');
});
// 這一條與上一條刻意重複:雙向斷言只保證兩邊一致,兩邊一起改就一起漂走。
// 把議題點名的那六個逐字釘在這裡,改動才需要有人明確地改掉這份清單。
test('被標記的正好是議題點名的那六個', () => {
assert.deepEqual(正本上的().map(鍵).sort(), [
'sdlc-analyze/算出截止日',
'sdlc-analyze/對四份清單列出疑點',
'sdlc-analyze/產生分析版的圖解總覽',
'sdlc-feat/把議題標題翻成英文',
'sdlc-feat/分批提交',
'sdlc-plan/產生圖解版總覽',
].sort());
});
// ── 判準第二條的迴歸保護 ───────────────────────────────────────────
/** 會問使用者的步驟。子代理問不到人,這些永遠不該被標上可委派。 */
const 會問使用者 = [
['sdlc-plan', '逐項詢問'],
['sdlc-analyze', '逐題問到共識'],
['sdlc-feat', '問來源分支'],
['sdlc-feat', '認出語言,讀規則正本'],
];
test('點名的問到共識類步驟一律未被標記', () => {
for (const [prompt, name] of 會問使用者) {
const step = promptSteps(readPrompt(prompt)).find((one) => one.name === name);
assert.ok(step, `${prompt} 少了「${name}」這一步;步驟改名的話這份清單要跟著改`);
assert.equal(step.marked, false, `${prompt}/${name} 會問使用者,判準第二條硬排除`);
}
});
test('任何看得出在問使用者的步驟都沒有被標記', () => {
// 只認明確的提問語,不認「不要拿去問使用者」那種否定句——那句正好出現在可委派的步驟裡
const 提問語 = /一次問一題|停下來問|等使用者回答|問到共識|問過使用者/;
let 掃過 = 0;
let 認出 = 0;
for (const prompt of PROMPTS) {
for (const step of promptSteps(readPrompt(prompt))) {
掃過 += 1;
if (!提問語.test(step.body)) continue;
認出 += 1;
assert.equal(step.marked, false, `${prompt}/${step.name} 在問使用者,不該標可委派`);
}
}
// 兩道自我檢查:這種掃描最常見的壞法是「一條都沒掃到」,而那時它照樣是綠的。
// 目前有編號步驟的是 plan/analyze/feat/report 四份,sdlc-fix 與 sdlc-sync 沒有編號步驟
assert.ok(掃過 >= 40, `只掃到 ${掃過} 個步驟,正本的步驟標題格式可能變了`);
assert.ok(認出 >= 4, `提問語一個步驟都沒認出來(${認出}),這道保護已經形同虛設`);
});
// ── 判準第四條:不直接寫入 ─────────────────────────────────────────
/** 會寫入 Gitea 或 git 的腳本。被標記的步驟碰到它們,就要寫明哪一半不委派。 */
const 寫入型 = [
'issue-create',
'issue-update',
'issue-link',
'project-add',
'pr-create',
'claim.js',
'branch-prep',
'worktree-ensure',
'worktree-remove',
'timer.js',
'time-log.js',
'commit-split',
];
test('被標記的步驟若碰得到寫入,就要寫明哪一半不委派', () => {
for (const step of 正本上的()) {
const 碰到 = 寫入型.filter((script) => step.body.includes(script));
if (碰到.length === 0) continue;
assert.match(
step.body,
/不委派/,
`${鍵(step)} 用到 ${碰到.join('、')},要寫明那一半留給主流程`,
);
}
});
test('三步部分委派的範圍,判準表上也說得出來', () => {
const 部分 = 表上的().filter((one) => one.範圍 !== '全步');
assert.equal(部分.length, 3, '兩份圖解總覽與分批提交是部分委派');
for (const one of 部分) {
assert.match(one.範圍, /不委派/, `${鍵(one)} 的範圍要說出哪一半不委派`);
}
});
test('另外三份正本一個標記都沒有,與判準正本結尾那句話一致', () => {
for (const prompt of PROMPTS.filter((one) => !有標記的正本.includes(one))) {
assert.equal(
readPrompt(prompt).includes(DELEGATABLE),
false,
`${prompt} 出現了標記,但 delegation.md 結尾說它沒有可委派的步驟`,
);
}
assert.match(reference, /`sdlc-sync`、`sdlc-fix` 與 `sdlc-report` 目前沒有可委派的步驟/);
});
// ── 平台中立 ───────────────────────────────────────────────────────
test('判準正本與標記都不指名任何平台的工具', () => {
for (const token of PLATFORM_SPECIFIC) {
assert.equal(reference.includes(token), false, `delegation.md 不該出現平台專屬字樣:${token}`);
}
// 子代理是平台專屬能力,正本只能以能力描述帶過
assert.match(reference, /能力描述/);
assert.match(reference, /不能就自己做/);
});
test('三份帶標記的正本各自說明了這個後綴是什麼意思,並指名判準正本', () => {
for (const prompt of 有標記的正本) {
const text = readPrompt(prompt);
assert.match(text, new RegExp(`## ${DELEGATABLE}的意思`), `${prompt} 要解釋這個後綴`);
assert.match(text, /references\/delegation\.md/, `${prompt} 要指名判準正本,不要把判準抄過去`);
assert.match(text, /不能就自己做/, `${prompt} 要寫成能力描述,讓不支援的平台自然降級`);
}
});
test('標記是標題後綴,不用 emoji 也不用 HTML 註解', () => {
for (const prompt of PROMPTS) {
const text = readPrompt(prompt);
assert.equal(text.includes('<!--'), false, `${prompt}:HTML 註解模型讀不穩,不拿它當標記`);
// 標記只出現在標題後綴與那一節的說明裡,不會單獨浮在內文中間
for (const line of text.split('\n')) {
if (!line.includes(DELEGATABLE)) continue;
// 三種合法位置,寫死成互斥的三條。曾經第二條寫成 /^#{2,3} /,把第一條整個
// 吃掉了——那時候「### 工作包議題 〔可委派〕」這種沒編號的標題也會通過
const 合法 =
new RegExp(`^### \\d+\\. .+ ${DELEGATABLE}$`).test(line) ||
line === `## ${DELEGATABLE}的意思` ||
line.includes(`\`${DELEGATABLE}\``);
assert.ok(合法, `${prompt}:標記出現在不該出現的位置:${line}`);
}
}
});
+38 -7
View File
@@ -66,20 +66,51 @@ export function assertNeutralPrompt(prompt, command) {
}
}
/** 可委派的標記。寫成標題後綴,不是 emoji 也不是 HTML 註解——那兩種模型讀不穩。 */
export const DELEGATABLE = '〔可委派〕';
/**
* 正本裡的每一個編號步驟:名字、有沒有被標成可委派、以及它的內文。
*
* 步驟的界線是下一個 `##` 或 `###` 標題;段落標題(`## 第二段…`)不算步驟,
* 但會把前一步收尾,否則一段的最後一步會把整個段落的收場白都吃進來。
* @param {string} prompt 正本內容
* @returns {{name: string, marked: boolean, body: string}[]}
*/
export function promptSteps(prompt) {
const lines = prompt.split('\n');
// 先收齊所有標題的行號,每一步的結尾就是它後面最近的那一個
const 標題行 = [];
lines.forEach((line, at) => {
if (/^#{2,3} /.test(line)) 標題行.push(at);
});
return 標題行
.map((at, i) => ({ at, 到: 標題行[i + 1] ?? lines.length }))
.filter(({ at }) => /^### \d+\. /.test(lines[at]))
.map(({ at, 到 }) => {
const raw = /^### \d+\. (.+)$/.exec(lines[at])[1].trim();
const marked = raw.endsWith(DELEGATABLE);
return {
name: (marked ? raw.slice(0, -DELEGATABLE.length) : raw).trim(),
marked,
body: lines.slice(at, 到).join('\n'),
};
});
}
/**
* 取出正本裡某一個編號步驟的內容,**以名字取而不是以編號取**。
* 步驟會增刪、編號會整批位移,名字不會;用編號寫的測試會在別人插一步時無聲地
* 框到另一段內容上,而那種失敗看起來像是正本掉了東西。
* @param {string} prompt 正本內容
* @param {string} name 步驟名,例如 '產生圖解版總覽'
* @returns {string} 該步驟的標題與內文,到下一個 `### ` 為止
* @param {string} name 步驟名,不含可委派後綴
* @returns {string} 該步驟的標題與內文,到下一個 `##` 或 `###` 標題為止
*/
export function promptStep(prompt, name) {
const start = prompt.search(new RegExp(`^### \\d+\\. ${name}$`, 'm'));
assert.ok(start >= 0, `正本裡找不到「${name}」這一步`);
const rest = prompt.slice(start);
const end = rest.slice(1).search(/^### /m);
return end === -1 ? rest : rest.slice(0, end + 1);
const step = promptSteps(prompt).find((one) => one.name === name);
assert.ok(step, `正本裡找不到「${name}」這一步`);
return step.body;
}
/**