feat/overview-artifact/main #32

Merged
admin merged 8 commits from feat/overview-artifact/main into master 2026-09-17 06:24:24 +00:00
9 changed files with 426 additions and 13 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
| --- | --- | --- | | --- | --- | --- |
| `prompts/` | 流程正本(`sdlc-{plan,analyze,feat,fix,sync,report}.md`),唯一的事實來源 | 平台中立 markdown,不含任何平台專屬語法 | | `prompts/` | 流程正本(`sdlc-{plan,analyze,feat,fix,sync,report}.md`),唯一的事實來源 | 平台中立 markdown,不含任何平台專屬語法 |
| `scripts/` | 所有副作用(Gitea API、git、檔案系統)的唯一出口 | Node、零外部套件,僅用內建 `fetch` / `child_process` / `fs` | | `scripts/` | 所有副作用(Gitea API、git、檔案系統)的唯一出口 | Node、零外部套件,僅用內建 `fetch` / `child_process` / `fs` |
| `templates/` | 所有產出格式(議題、PR、報表、總覽網頁) | 以 `{{變數}}` 佔位,不含邏輯 | | `templates/` | 所有產出格式(議題、PR、報表、總覽網頁) | 以 `{{變數}}` 佔位,不含邏輯。唯一例外是 `overview-artifact.html`:它是一份要在瀏覽器裡開的網頁,需要一段把 mermaid 圖畫出來的腳本 |
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單) | 由流程正本指名讀取,不自行散落於 prompts | | `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單) | 由流程正本指名讀取,不自行散落於 prompts |
| `install.js` | 平台偵測與轉接檔產生 | 唯一知道各平台目錄結構的地方 | | `install.js` | 平台偵測與轉接檔產生 | 唯一知道各平台目錄結構的地方 |
| `skills/` | 各助理原生 plugin 機制讀取的 skills | 目前為空;指令以轉接檔形式佈署 | | `skills/` | 各助理原生 plugin 機制讀取的 skills | 目前為空;指令以轉接檔形式佈署 |
+27 -1
View File
@@ -176,7 +176,33 @@ node scripts/project-add.js --repo <owner/name> --index <編號> --project "<看
看板名稱靠掃最近 50 筆議題反查 id,反查不到就會請你直接貼專案網址(結尾即 id)。 看板名稱靠掃最近 50 筆議題反查 id,反查不到就會請你直接貼專案網址(結尾即 id)。
本流程不建立 Milestone,也不建立專案。 本流程不建立 Milestone,也不建立專案。
### 11. 回報 ### 11. 產生分析版的圖解總覽
用同一份 `templates/overview-artifact.html` 再產一份,但這一份要多出**工作包全景**:
把工作包之間的相依與截止日畫成一張圖,讓開發者看得出自己這一項在整體中的位置。
全景圖用 `graph TD`,填進 `{{工作包全景}}`,連同段落標題一起:
```
<section><h2>工作包全景</h2>
<figure><div class="mermaid">graph TD
A[建立共用函式庫<br/>09-25] --> B[建立抽取契約<br/>09-27]
</div></figure></section>
```
節點寫工作包標題與截止日,箭頭方向是「先決 → 後續」。節點一樣以 12 個為上限,
超過就只畫相依鏈最長路徑上的那幾顆,其餘在頁尾列成文字。
網址一樣寫回需求議題:
```
node scripts/issue-update.js --repo <owner/name> --index <需求議題編號> --overview-url <網址>
```
**重跑會就地更新同一行**,不會在議題上留下兩個連結。規劃階段產生的那一份會被這一份取代,
這是預期行為——同一顆需求議題只掛一個總覽網址。
### 12. 回報
列出每顆工作包的編號、標題、截止日與所屬 Milestone,並指出**相依鏈最長路徑**上的那幾顆 列出每顆工作包的編號、標題、截止日與所屬 Milestone,並指出**相依鏈最長路徑**上的那幾顆
——那條路徑決定整體交期。 ——那條路徑決定整體交期。
+28 -1
View File
@@ -69,7 +69,34 @@ node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file
同一段需求重跑不會產生第二顆議題:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆 同一段需求重跑不會產生第二顆議題:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆
並把 `created` 設為 `false`。 並把 `created` 設為 `false`。
### 6. 回報 ### 6. 產生圖解版總覽
套用 `templates/overview-artifact.html`,把議題的總覽、目標與流程圖填成一份可以直接投影的
網頁。這一份是給**非技術的利害關係人**看的:他們不必讀完技術細節就知道這件事在做什麼。
模板的佔位對應如下,樣式不要動——版面與內容分開,改一邊不必碰另一邊:
- `{{標題}}` 需求議題標題
- `{{來源議題}}` 指回議題的連結
- `{{總覽}}` 一句話總覽
- `{{目標}}` 目標,逐條包成 `<li>`
- `{{流程圖}}` 流程圖的 Mermaid 原始碼(**不含**圍欄,圍欄是議題 markdown 用的)
- `{{工作包全景}}` 規劃階段還沒有工作包,**填空字串**;這一段由分析階段補上
- `{{頁尾}}` 產生時間與產生者
若執行環境能把 HTML 發佈成可分享的網址,就發佈;不能的話存成檔案,把路徑當成網址用。
拿到網址後寫回議題:
```
node scripts/issue-update.js --repo <owner/name> --index <編號> --overview-url <網址>
```
它把連結以固定前綴寫成總覽段落裡的一行,**重跑時就地更新同一行**,不會長出第二個連結;
議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。連結旁會自動附上
「此連結預設為私有,組織外無法開啟」,因為讀到的人多半會想轉寄給組織外的人。
### 7. 回報
把議題編號與網址告訴使用者。不要把整份議題內容再貼一次 —— 連結點進去就看得到。 把議題編號與網址告訴使用者。不要把整份議題內容再貼一次 —— 連結點進去就看得到。
+34 -7
View File
@@ -9,10 +9,13 @@
* *
* Milestone 只認既有的:指到不存在的就中止並列出可選項目,本工具不建立 Milestone。 * Milestone 只認既有的:指到不存在的就中止並列出可選項目,本工具不建立 Milestone。
* *
* 也負責把圖解版總覽的網址寫回議題:連結以固定前綴獨佔一行,重跑時就地更新,
* 議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。
*
* 用法: * 用法:
* node scripts/issue-update.js --repo owner/name --index 12 * node scripts/issue-update.js --repo owner/name --index 12
* [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N] * [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N]
* [--host <網址>] [--dry-run] * [--overview-url <網址>] [--host <網址>] [--dry-run]
*/ */
import { import {
ScriptError, ScriptError,
@@ -27,21 +30,25 @@ import {
} from './lib.js'; } from './lib.js';
import { upsertLineInSection } from './issue-body.js'; import { upsertLineInSection } from './issue-body.js';
/** artifact 預設私有,組織外開不起來——這件事要跟著連結一起留在議題上 */
const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)';
main(async () => { main(async () => {
const flags = parseFlags(process.argv.slice(2), { const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index'], required: ['repo', 'index'],
optional: ['milestone', 'due-date', 'estimate-days', 'host'], optional: ['milestone', 'due-date', 'estimate-days', 'overview-url', 'host'],
booleans: ['dry-run'], booleans: ['dry-run'],
}); });
const repo = parseRepo(flags.repo); const repo = parseRepo(flags.repo);
const index = parseIndex(flags.index); const index = parseIndex(flags.index);
const dueDate = parseDueDate(flags['due-date']); const dueDate = parseDueDate(flags['due-date']);
const days = parseDays(flags['estimate-days']); const days = parseDays(flags['estimate-days']);
const overviewUrl = parseOverviewUrl(flags['overview-url']);
if (flags.milestone === undefined && dueDate === null && days === null) { if (flags.milestone === undefined && dueDate === null && days === null && overviewUrl === null) {
throw new ScriptError( throw new ScriptError(
'NOTHING_TO_UPDATE', 'NOTHING_TO_UPDATE',
'至少要指定 --milestone、--due-date 或 --estimate-days 其中一個', '至少要指定 --milestone、--due-date、--estimate-days 或 --overview-url 其中一個',
); );
} }
@@ -57,11 +64,16 @@ main(async () => {
if (dueDate !== null) { if (dueDate !== null) {
payload.due_date = `${dueDate}T00:00:00Z`; payload.due_date = `${dueDate}T00:00:00Z`;
} }
if (days !== null) { if (days !== null || overviewUrl !== null) {
const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`); const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`);
const updated = upsertLineInSection(issue.body ?? '', '關聯', `估算人天:${days}`); let body = issue.body ?? '';
if (days !== null) body = upsertLineInSection(body, '關聯', `估算人天:${days}`);
if (overviewUrl !== null) {
body = upsertLineInSection(body, '總覽', `圖解版總覽:${overviewUrl}${PRIVACY_NOTE}`);
}
// 沒變就不塞進 PATCH:無謂改寫 body 會在議題上留下一筆沒有內容的編輯紀錄 // 沒變就不塞進 PATCH:無謂改寫 body 會在議題上留下一筆沒有內容的編輯紀錄
if (updated !== issue.body) payload.body = updated; if (body !== issue.body) payload.body = body;
} }
if (flags['dry-run']) { if (flags['dry-run']) {
@@ -86,6 +98,21 @@ function parseDueDate(value) {
return value; return value;
} }
function parseOverviewUrl(value) {
if (value === undefined) return null;
if (!/^https?:\/\/\S+$/.test(value)) {
throw new ScriptError('BAD_OVERVIEW_URL', `--overview-url 需為 http(s) 網址,收到的是 ${value}`);
}
// 連上一次寫回的整行一起複製貼上是很常見的手誤,放行的話那句提醒會在議題上出現兩次
if (value.includes(PRIVACY_NOTE)) {
throw new ScriptError(
'BAD_OVERVIEW_URL',
`--overview-url 夾帶了上一次寫回的提醒文字,請只給網址本身:${value}`,
);
}
return value;
}
function parseDays(value) { function parseDays(value) {
if (value === undefined) return null; if (value === undefined) return null;
const days = Number(value); const days = Number(value);
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{標題}}</title>
<style>
/* 樣式全部集中在這裡,內文只放佔位——改版面不必動內容,換內容不必碰樣式 */
:root {
--bg: #ffffff;
--fg: #1f2328;
--muted: #59636e;
--line: #d1d9e0;
--accent: #0969da;
--surface: #f6f8fa;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #9198a1;
--line: #3d444d;
--accent: #4493f8;
--surface: #151b23;
}
}
:root[data-theme="dark"] {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #9198a1;
--line: #3d444d;
--accent: #4493f8;
--surface: #151b23;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 48px 16px 96px;
background: var(--bg);
color: var(--fg);
font: 16px/1.7 -apple-system, "Noto Sans TC", "Microsoft JhengHei", sans-serif;
}
main { max-width: 900px; margin: 0 auto; }
header { border-bottom: 1px solid var(--line); padding-bottom: 24px; margin-bottom: 40px; }
h1 { font-size: 30px; line-height: 1.3; margin: 0 0 12px; }
.meta { color: var(--muted); font-size: 14px; }
.meta a { color: var(--accent); text-decoration: none; }
.meta a:hover { text-decoration: underline; }
.lede {
font-size: 21px;
line-height: 1.6;
margin: 0 0 40px;
padding: 20px 24px;
background: var(--surface);
border-left: 3px solid var(--accent);
border-radius: 0 8px 8px 0;
}
section { margin-bottom: 40px; }
h2 { font-size: 15px; letter-spacing: .08em; text-transform: uppercase;
color: var(--muted); margin: 0 0 16px; font-weight: 600; }
ul { margin: 0; padding-left: 22px; }
li { margin-bottom: 8px; }
figure { margin: 0; padding: 24px; background: var(--surface);
border: 1px solid var(--line); border-radius: 8px; overflow-x: auto; }
figure .mermaid { display: flex; justify-content: center; }
footer { margin-top: 56px; padding-top: 20px; border-top: 1px solid var(--line);
color: var(--muted); font-size: 13px; }
@media (max-width: 600px) {
body { padding: 32px 16px 64px; }
h1 { font-size: 24px; }
.lede { font-size: 18px; padding: 16px 18px; }
}
</style>
</head>
<body>
<main>
<header>
<h1>{{標題}}</h1>
<p class="meta">{{來源議題}}</p>
</header>
<p class="lede">{{總覽}}</p>
<section>
<h2>目標</h2>
<ul>{{目標}}</ul>
</section>
<section>
<h2>流程</h2>
<figure><div class="mermaid">{{流程圖}}</div></figure>
</section>
{{工作包全景}}
<footer>{{頁尾}}</footer>
</main>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
mermaid.initialize({ startOnLoad: true, theme: dark ? 'dark' : 'default' });
</script>
</body>
</html>
+7 -2
View File
@@ -19,9 +19,14 @@ export function readReference(name) {
return readFileSync(join(repoRoot, 'references', `${name}.md`), 'utf8'); return readFileSync(join(repoRoot, 'references', `${name}.md`), 'utf8');
} }
/** 讀一份輸出模板 */ /** 讀一份輸出模板(markdown) */
export function readTemplate(name) { export function readTemplate(name) {
return readFileSync(join(repoRoot, 'templates', `${name}.md`), 'utf8'); return readTemplateFile(`${name}.md`);
}
/** 讀 templates/ 底下任一個檔案,含副檔名 */
export function readTemplateFile(filename) {
return readFileSync(join(repoRoot, 'templates', filename), 'utf8');
} }
/** /**
+90
View File
@@ -249,3 +249,93 @@ test('估算插在段落內容結尾,不會掉到下一個段落裡', async (t
'估算要留在關聯段落內', '估算要留在關聯段落內',
); );
}); });
// ── 總覽網頁的連結 ─────────────────────────────────────────────────
test('把總覽網頁的連結寫進總覽段落', async (t) => {
const stub = await withStub(t, {}, {
body: '## 總覽\n\n一句話總覽。\n\n## 關聯\n\n需求議題:#1\n',
});
await run(['--overview-url', 'https://example.com/artifact/abc'], stub);
const updated = patchOf(stub).body.body;
const overview = updated.slice(0, updated.indexOf('## 關聯'));
assert.match(overview, /圖解版總覽:https:\/\/example\.com\/artifact\/abc/);
});
test('連結旁註明 artifact 預設私有', async (t) => {
const stub = await withStub(t, {}, { body: '## 總覽\n\n一句話總覽。\n' });
await run(['--overview-url', 'https://example.com/a'], stub);
assert.match(patchOf(stub).body.body, /私有/);
assert.match(patchOf(stub).body.body, /組織外/);
});
test('議題原本的 markdown 白話總覽保留不動', async (t) => {
const stub = await withStub(t, {}, {
body: '## 總覽\n\n這段白話總覽不該被網頁取代。\n\n## 背景\n\n背景說明。\n',
});
await run(['--overview-url', 'https://example.com/a'], stub);
const updated = patchOf(stub).body.body;
assert.match(updated, /這段白話總覽不該被網頁取代。/);
assert.match(updated, /## 背景\n\n背景說明。/);
});
test('重跑時原地更新同一行,不會長出第二個連結', async (t) => {
const stub = await withStub(t, {}, {
body: '## 總覽\n\n一句話。\n圖解版總覽:https://example.com/old(此連結預設為私有,組織外無法開啟)\n',
});
await run(['--overview-url', 'https://example.com/new'], stub);
const updated = patchOf(stub).body.body;
assert.equal((updated.match(/圖解版總覽:/g) ?? []).length, 1);
assert.match(updated, /example\.com\/new/);
assert.equal(updated.includes('example.com/old'), false);
});
test('連結沒變時不重寫 body', async (t) => {
const stub = await withStub(t, {}, {
body: '## 總覽\n\n一句話。\n圖解版總覽:https://example.com/a(此連結預設為私有,組織外無法開啟)\n',
});
await run(['--overview-url', 'https://example.com/a'], stub);
assert.equal('body' in patchOf(stub).body, false);
});
test('不是網址時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--overview-url', '不是網址'], stub);
assert.equal(json.error.code, 'BAD_OVERVIEW_URL');
assert.equal(stub.requests.length, 0);
});
test('總覽網頁與其他欄位可以一次送出', async (t) => {
const stub = await withStub(t);
await run(['--overview-url', 'https://example.com/a', '--due-date', '2026-09-24'], stub);
const patches = stub.requests.filter((r) => r.method === 'PATCH' && !r.path.endsWith('/0'));
assert.equal(patches.length, 1);
assert.match(patches[0].body.body, /圖解版總覽/);
assert.equal(patches[0].body.due_date, '2026-09-24T00:00:00Z');
});
test('網址夾帶上一次寫回的提醒文字時擋下,免得那句話重複兩次', async (t) => {
const stub = await withStub(t);
const { json } = await run(
['--overview-url', 'https://example.com/a(此連結預設為私有,組織外無法開啟)'],
stub,
);
assert.equal(json.error.code, 'BAD_OVERVIEW_URL');
assert.equal(stub.requests.length, 0);
});
+126
View File
@@ -0,0 +1,126 @@
/**
* 圖解版總覽網頁:模板本身,以及兩份正本裡產生它、把網址寫回議題的規則。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readPrompt, readTemplateFile } from './helpers/prompt-doc.js';
const template = readTemplateFile('overview-artifact.html');
const planPrompt = readPrompt('sdlc-plan');
const analyzePrompt = readPrompt('sdlc-analyze');
/** 只看產生總覽那一步,避免拿整份正本的任何一處來充數 */
const planStep = planPrompt.slice(planPrompt.indexOf('### 6.'), planPrompt.indexOf('### 7.'));
/** 模板要填的欄位 */
const PLACEHOLDERS = ['標題', '來源議題', '總覽', '目標', '流程圖', '工作包全景', '頁尾'];
// ── 模板 ───────────────────────────────────────────────────────────
test('模板以 {{變數}} 佔位,欄位齊全', () => {
const found = new Set([...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1]));
for (const name of PLACEHOLDERS) {
assert.ok(found.has(name), `模板缺少佔位 {{${name}}}`);
}
});
test('樣式與內容分離:樣式集中在 style 區塊,內文不帶 style 屬性', () => {
const styleBlocks = template.match(/<style>[\s\S]*?<\/style>/g) ?? [];
assert.equal(styleBlocks.length, 1, '樣式應集中在單一 style 區塊');
const body = template.slice(template.indexOf('<body>'));
assert.equal(/\sstyle="/.test(body), false, '內文不該出現行內樣式');
});
test('是一份可以直接開的完整 HTML', () => {
assert.match(template, /^<!DOCTYPE html>/);
assert.match(template, /<html lang="zh-Hant">/);
assert.match(template, /<meta charset="utf-8">/);
assert.match(template, /<meta name="viewport"/, '要能在投影與手機上都看得清楚');
});
test('深色模式不靠手動切換也能用', () => {
assert.match(template, /prefers-color-scheme: dark/);
});
test('mermaid 圖有被實際渲染,不是把原始碼丟給讀者看', () => {
// 只比對「有出現 mermaid 字樣」會被 CDN 網址矇混過去,要驗到真的有初始化
const script = template.match(/<script[\s\S]*?<\/script>/)?.[0] ?? '';
assert.match(script, /mermaid\.initialize\(/);
assert.match(template, /class="mermaid"/);
});
test('模板裡唯一的腳本就是畫圖那一段,沒有夾帶其他邏輯', () => {
// AGENTS.md 說模板不含邏輯,這份是唯一的例外,例外要維持在最小範圍
const scripts = template.match(/<script[\s\S]*?<\/script>/g) ?? [];
assert.equal(scripts.length, 1);
assert.equal(/\bfetch\(|localStorage|document\.cookie|XMLHttpRequest/.test(scripts[0]), false);
});
test('工作包全景是整段佔位,規劃階段才填得了空字串', () => {
// 規劃階段沒有工作包,整段要能消失,所以佔位不可以被包在寫死的 section 裡
const line = template.split('\n').find((l) => l.includes('{{工作包全景}}'));
assert.equal(line.trim(), '{{工作包全景}}');
});
// ── sdlc-plan 的步驟 ───────────────────────────────────────────────
test('規劃正本交代了產生總覽與寫回網址', () => {
assert.match(planStep, /templates\/overview-artifact\.html/);
assert.match(planStep, /--overview-url/);
assert.match(planStep, /issue-update/);
});
test('規劃正本說明流程圖填進模板時不帶圍欄', () => {
assert.match(planStep, /不\*\*含\*\*圍欄|\*\*不含\*\*圍欄/);
});
test('規劃階段沒有工作包,正本要說清楚全景填空字串', () => {
assert.match(planStep, /\{\{工作包全景\}\}/);
assert.match(planStep, /填空字串/);
});
test('正本說明這份網頁是給非技術的人看的', () => {
assert.match(planStep, /非技術/);
});
// ── sdlc-analyze 的步驟 ────────────────────────────────────────────
test('分析正本的全景圖用 graph TD,並畫出相依與時程', () => {
const step = analyzePrompt.slice(analyzePrompt.indexOf('### 11.'));
assert.match(step, /graph TD/);
assert.match(step, /相依/);
assert.match(step, /截止日/);
});
test('分析正本沿用同一份模板,不另立一份', () => {
assert.match(analyzePrompt, /同一份 `templates\/overview-artifact\.html`/);
});
test('全景圖一樣有節點上限,超過時的做法有交代', () => {
const step = analyzePrompt.slice(analyzePrompt.indexOf('### 11.'));
assert.match(step, /12/);
assert.match(step, /最長路徑/);
});
test('分析正本說明重跑會就地更新,不會留下兩個連結', () => {
const step = analyzePrompt.slice(analyzePrompt.indexOf('### 11.'));
assert.match(step, /就地更新/);
assert.match(step, /只掛一個總覽網址/);
});
// ── 兩份共通 ───────────────────────────────────────────────────────
test('兩份正本都指名同一支腳本寫回網址', () => {
for (const [name, prompt] of [['sdlc-plan', planPrompt], ['sdlc-analyze', analyzePrompt]]) {
assert.match(prompt, /--overview-url/, `${name} 沒有指名寫回網址的方式`);
}
});
test('規劃正本說明連結會自動附上私有提醒', () => {
assert.match(planStep, /私有/);
assert.match(planStep, /組織外/);
});
test('規劃正本強調議題原本的白話總覽不被取代', () => {
assert.match(planStep, /網頁是補充,不是取代/);
});
+1 -1
View File
@@ -17,7 +17,7 @@ import {
const template = readTemplate('work-package-issue'); const template = readTemplate('work-package-issue');
const prompt = readPrompt('sdlc-analyze'); const prompt = readPrompt('sdlc-analyze');
/** 第二段的內容,避免把第一段的字樣誤認成這一段的規則 */ /** 第二段的內容,避免把第一段的字樣誤認成這一段的規則 */
const phase2 = prompt.slice(prompt.indexOf('## 第二段')); const phase2 = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 第三段'));
/** 工作包議題的九個段落,順序即議題裡的順序 */ /** 工作包議題的九個段落,順序即議題裡的順序 */
const SECTIONS = [ const SECTIONS = [