@loviusc/sa-spec 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +155 -0
- package/README.ja.md +143 -0
- package/README.ko.md +143 -0
- package/README.zh-CN.md +143 -0
- package/README.zh-TW.md +779 -0
- package/package.json +13 -3
- package/readme.md +85 -30
- package/src/cli.js +225 -40
- package/templates/AGENTS.md +4 -3
- package/templates/readme.md +85 -30
- package/templates/skills/system-analysis/SKILL.md +5 -4
package/package.json
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loviusc/sa-spec",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Personal system analysis documentation workspace CLI",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
7
7
|
"registry": "https://registry.npmjs.org/"
|
|
8
8
|
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/lovius215/ai_spec.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/lovius215/ai_spec#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/lovius215/ai_spec/issues"
|
|
16
|
+
},
|
|
9
17
|
"bin": {
|
|
10
18
|
"sa-spec": "bin/sa-spec.js"
|
|
11
19
|
},
|
|
@@ -13,7 +21,8 @@
|
|
|
13
21
|
"bin/",
|
|
14
22
|
"src/",
|
|
15
23
|
"templates/",
|
|
16
|
-
"readme.md"
|
|
24
|
+
"readme.md",
|
|
25
|
+
"README*.md"
|
|
17
26
|
],
|
|
18
27
|
"scripts": {
|
|
19
28
|
"check": "node --check src/cli.js && node --check bin/sa-spec.js"
|
|
@@ -22,7 +31,8 @@
|
|
|
22
31
|
"system-analysis",
|
|
23
32
|
"requirements",
|
|
24
33
|
"srs",
|
|
25
|
-
"documentation"
|
|
34
|
+
"documentation",
|
|
35
|
+
"i18n"
|
|
26
36
|
],
|
|
27
37
|
"license": "MIT"
|
|
28
38
|
}
|
package/readme.md
CHANGED
|
@@ -1,4 +1,41 @@
|
|
|
1
|
-
# 個人系統分析 AI 工作台
|
|
1
|
+
# 個人系統分析 AI 工作台 / System Analysis AI Workspace
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@loviusc/sa-spec)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
> A lightweight system analysis documentation workspace CLI that turns ideas into verifiable, auditable, and conflict-checked specifications for AI-driven development.
|
|
7
|
+
> 輕量級系統分析文件工作台,用固定流程把需求想法整理成可確認、可歸檔、可檢查的規格文件。
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<b>🌐 Languages:</b>
|
|
11
|
+
<a href="./README.en.md"><b>English</b></a> |
|
|
12
|
+
<a href="./README.zh-TW.md"><b>繁體中文</b></a> |
|
|
13
|
+
<a href="./README.zh-CN.md"><b>简体中文</b></a> |
|
|
14
|
+
<a href="./README.ja.md"><b>日本語</b></a> |
|
|
15
|
+
<a href="./README.ko.md"><b>한국어</b></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
### Quick Start (English)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Global installation via npm
|
|
24
|
+
npm install -g @loviusc/sa-spec
|
|
25
|
+
|
|
26
|
+
# Initialize project with English specification rules
|
|
27
|
+
sa-spec init --full --lang en
|
|
28
|
+
|
|
29
|
+
# Create a feature draft branch
|
|
30
|
+
sa-spec new --name cart-checkout --branch
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Core AI Workflow in VS Code:**
|
|
34
|
+
`spec:init (sci)` $\to$ `spec:propose (scp)` $\to$ `spec:analyze (sca)` $\to$ `spec:commit (scc)`
|
|
35
|
+
|
|
36
|
+
👉 **Read full English documentation: [README.en.md](./README.en.md)**
|
|
37
|
+
|
|
38
|
+
---
|
|
2
39
|
|
|
3
40
|
`sa-spec` 是一個輕量的系統分析文件工作台,用固定流程把需求想法整理成可確認、可歸檔、可檢查的文件。
|
|
4
41
|
|
|
@@ -360,45 +397,51 @@ docs(REQ-001): 番茄時鐘 - 新增專注與休息計時需求
|
|
|
360
397
|
|
|
361
398
|
```mermaid
|
|
362
399
|
flowchart TD
|
|
363
|
-
A[開新需求: sa-spec new --
|
|
364
|
-
B --> C[AI 分析與起草: sca<br/>內部使用 {{REQ_ID}} 佔位<br/>自動同步 master 規格防護]
|
|
365
|
-
C --> D{使用者確認通過<br/>執行 scc 或 finalize}
|
|
366
|
-
D --> E[
|
|
367
|
-
E --> F[
|
|
368
|
-
F --> G[
|
|
369
|
-
G --> H[
|
|
400
|
+
A["開新需求: scp 或 sa-spec new --branch"] --> B["建立並切換至 feat/cart 分支<br/>以 project/cart/ 建立草稿目錄"]
|
|
401
|
+
B --> C["AI 分析與起草: sca<br/>內部使用 {{REQ_ID}} 佔位<br/>自動同步 master 規格防護"]
|
|
402
|
+
C --> D{"使用者確認通過<br/>執行 scc 或 finalize"}
|
|
403
|
+
D --> E["自動查詢主分支最新序號 N"]
|
|
404
|
+
E --> F["自動定號為 project/REQ-N+1-cart/<br/>批次置換 {{REQ_ID}} 並建立 commit"]
|
|
405
|
+
F --> G["自動合併至 master 主分支<br/>動態重新生成 project/index.md"]
|
|
406
|
+
G --> H["完成閉環:零衝突、序號永遠連續!"]
|
|
370
407
|
```
|
|
371
408
|
|
|
372
409
|
### 多人協作四步流程
|
|
373
410
|
|
|
374
411
|
#### 1. 提出需求(草稿與分支隔離)
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
412
|
+
開需求時**不搶序號**,直接以語意名稱建立分支與草稿目錄。支援兩種方式:
|
|
413
|
+
|
|
414
|
+
* **方式 A(純 AI 自動化,最推薦)**:在 AI 側邊欄直接輸入:
|
|
415
|
+
```text
|
|
416
|
+
scp 購物車結帳需求
|
|
417
|
+
```
|
|
418
|
+
AI 收到後,會**自動在背景執行 `git checkout -b feat/cart-checkout` 建立並切換分支**,並在 `project/cart-checkout/` 建立草稿目錄!
|
|
419
|
+
* **方式 B(CLI 終端指令)**:
|
|
420
|
+
```bash
|
|
421
|
+
sa-spec new --name cart-checkout --branch
|
|
422
|
+
```
|
|
385
423
|
* **特點**:目錄名稱為 `project/cart-checkout/`,內部所有檔案皆使用 `{{REQ_ID}}` 佔位符。多人同時提出需求互不干擾、零衝突。即使需求中途放棄,直接刪除分支即可,絕不留下幽靈跳號。
|
|
386
424
|
|
|
387
425
|
#### 2. 分析與衝突檢查(規格基線同步)
|
|
388
426
|
在分支上執行 `spec:analyze`(或 `sca`)前,工具會確認已取得 `master`(或 `origin/master`)的最新規格,確保是以全專案最新基準進行影響評估,避免因分支落後而漏檢權限、API 或資料表衝突。
|
|
389
427
|
|
|
390
|
-
#### 3.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
1.
|
|
399
|
-
2.
|
|
400
|
-
3.
|
|
401
|
-
4.
|
|
428
|
+
#### 3. 確認歸檔與延遲定號(自動領號 + 自動合併回主分支)
|
|
429
|
+
當需求規格經使用者確認無誤,準備歸檔時:
|
|
430
|
+
|
|
431
|
+
* **方式 A(純 AI 自動化,最推薦)**:在 AI 側邊欄直接輸入:
|
|
432
|
+
```text
|
|
433
|
+
scc
|
|
434
|
+
```
|
|
435
|
+
AI 收到後會自動完成全自動 Git-Flow 閉環作業:
|
|
436
|
+
1. **自動向主分支查詢並取得新號**:查詢 master/main 目前最大序號(例如最大是 `REQ-005`,自動領取最新號 `REQ-006`),避免號碼競爭。
|
|
437
|
+
2. **自動定名更名**:使用 `git mv` 將目錄重命名為 `project/REQ-006-cart-checkout`,並將目錄內所有檔案的 `{{REQ_ID}}` 批次置換為 `REQ-006`。
|
|
438
|
+
3. **自動建立本機提交**:在 feature 分支自動建立 commit `docs(REQ-006): cart-checkout - 規格確認歸檔`。
|
|
439
|
+
4. **自動合併至主分支**:自動切換至主分支(`git checkout master`),將 feature 分支自動合併回主分支(`git merge feat/cart-checkout`),並重新生成主分支的 `project/index.md`,保證主分支規格最新且永遠不衝突!
|
|
440
|
+
* **方式 B(CLI 終端指令)**:
|
|
441
|
+
```bash
|
|
442
|
+
sa-spec finalize --name cart-checkout
|
|
443
|
+
```
|
|
444
|
+
CLI 同樣會自動查詢主分支最新序號定號、置換佔位符、完成提交並自動合併回 master!若想保留在當前分支手動處理,可加上 `--no-merge` 參數。
|
|
402
445
|
|
|
403
446
|
#### 4. 消除索引合併衝突(`sa-spec reindex`)
|
|
404
447
|
`project/index.md` 改為由目錄動態生成的「衍生清單」,不再依賴脆弱的手動 append:
|
|
@@ -681,6 +724,18 @@ git push origin master
|
|
|
681
724
|
|
|
682
725
|
## 更新紀錄
|
|
683
726
|
|
|
727
|
+
### v0.2.3
|
|
728
|
+
- **自動領號與自動合併至主分支(Git-Flow 自動化閉環)**:
|
|
729
|
+
- **自動從主分支取新號**:執行 `spec:commit`(`scc`)或 `sa-spec finalize` 時,自動向主分支(`master`/`main`)查詢最新已使用最大序號並定號(`REQ-xxx`),自動置換內部佔位符 `{{REQ_ID}}`,徹底防止多人協作時在 feature 分支發生的號碼撞車與落後問題。
|
|
730
|
+
- **自動合併回主分支**:完成 feature 分支 commit 後,自動切換至主分支(`git checkout master`)並將該 feature 分支自動合併回主分支(`git merge feat/<name>`),並在主分支自動重新整理 `project/index.md`,打造完全自動化的 Git-Flow 零衝突閉環。
|
|
731
|
+
- **CLI 自動化支援**:`sa-spec finalize --name <name>` 預設自動執行主分支序號運算、定號置換、提交與合併回主分支(支援 `--no-merge` 參數跳過自動合併)。
|
|
732
|
+
|
|
733
|
+
### v0.2.2
|
|
734
|
+
- **npm 首頁與專案文件全多語系化(Multi-Language Documentation on npm)**:
|
|
735
|
+
- **5 國獨立完整說明文件**:新增 `README.en.md`(英文)、`README.zh-CN.md`(簡中)、`README.ja.md`(日文)、`README.ko.md`(韓文)與 `README.zh-TW.md`(繁中)。
|
|
736
|
+
- **npm 國際化首頁最佳化**:在主 `readme.md` 頂部加入醒目的 5 語系導覽切換列與英文 Quick Start 區塊,全球開發者造訪 npm 頁面時能秒懂工具用途與使用方式。
|
|
737
|
+
- **npm 發布配置補全**:在 `package.json` 補充 `repository`、`homepage` 與 `bugs` 欄位,確保 npm 頁面上的多語系相對連結能自動正確導向 GitHub。
|
|
738
|
+
|
|
684
739
|
### v0.2.1
|
|
685
740
|
- **5 國語言與國際化支援(i18n)**:
|
|
686
741
|
- **支援語系**:繁體中文 (`zh-TW`,台灣常用語)、英文 (`en`)、簡體中文 (`zh-CN`)、日文 (`ja`)、韓文 (`ko`)。
|
package/src/cli.js
CHANGED
|
@@ -177,10 +177,48 @@ function runCheck() {
|
|
|
177
177
|
if (!checkRequiredPaths(null)) {
|
|
178
178
|
return;
|
|
179
179
|
}
|
|
180
|
+
checkDuplicateReqIds();
|
|
180
181
|
console.log("");
|
|
181
182
|
showStatus(false);
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
function checkDuplicateReqIds() {
|
|
186
|
+
const projectDir = path.join(cwd, "project");
|
|
187
|
+
if (!fs.existsSync(projectDir)) return true;
|
|
188
|
+
|
|
189
|
+
const entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
190
|
+
const idDirsMap = new Map();
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
if (entry.isDirectory() && entry.name.startsWith("REQ-")) {
|
|
193
|
+
const m = entry.name.match(/^(REQ-\d+)/i);
|
|
194
|
+
if (m) {
|
|
195
|
+
const id = m[1].toUpperCase();
|
|
196
|
+
if (!idDirsMap.has(id)) idDirsMap.set(id, []);
|
|
197
|
+
idDirsMap.get(id).push(entry.name);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const duplicates = [];
|
|
203
|
+
for (const [id, dirs] of idDirsMap.entries()) {
|
|
204
|
+
if (dirs.length > 1) {
|
|
205
|
+
duplicates.push({ id, dirs });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (duplicates.length > 0) {
|
|
210
|
+
console.log("序號唯一性: WARNING (發現重複編號)");
|
|
211
|
+
for (const dup of duplicates) {
|
|
212
|
+
console.log(` - ${dup.id} 重複出現在: ${dup.dirs.join(", ")}`);
|
|
213
|
+
}
|
|
214
|
+
console.log(" 提示:請將衝突目錄重新定號以維持序號連續與唯一。");
|
|
215
|
+
return false;
|
|
216
|
+
} else {
|
|
217
|
+
console.log("序號唯一性: OK");
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
184
222
|
function showStatus(showHeading = true) {
|
|
185
223
|
if (showHeading) {
|
|
186
224
|
console.log("sa-spec status");
|
|
@@ -309,31 +347,27 @@ function createReqTemplate(args, label = "new") {
|
|
|
309
347
|
printResultList(label, results);
|
|
310
348
|
}
|
|
311
349
|
|
|
312
|
-
function
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
return;
|
|
350
|
+
function detectMainBranch() {
|
|
351
|
+
const branches = ["main", "master"];
|
|
352
|
+
for (const b of branches) {
|
|
353
|
+
const res = spawnSync("git", ["rev-parse", "--verify", b], { cwd, encoding: "utf8" });
|
|
354
|
+
if (res.status === 0) return b;
|
|
317
355
|
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
318
358
|
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (!fs.existsSync(draftAbsDir)) {
|
|
325
|
-
const altRelDir = path.join("project", `draft-${slug}`);
|
|
326
|
-
if (fs.existsSync(path.join(cwd, altRelDir))) {
|
|
327
|
-
draftRelDir = altRelDir;
|
|
328
|
-
draftAbsDir = path.join(cwd, altRelDir);
|
|
329
|
-
} else {
|
|
330
|
-
fail(`找不到草稿需求目錄: ${draftRelDir}`);
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
359
|
+
function getCurrentBranch() {
|
|
360
|
+
const res = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, encoding: "utf8" });
|
|
361
|
+
if (res.status === 0) {
|
|
362
|
+
const b = res.stdout.trim();
|
|
363
|
+
return b === "HEAD" ? null : b;
|
|
333
364
|
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
334
367
|
|
|
335
|
-
|
|
368
|
+
function getLatestMaxReqId(projectDir, mainBranch) {
|
|
336
369
|
let maxIdNum = 0;
|
|
370
|
+
// 1. 掃描本地工作目錄既有 REQ-xxx
|
|
337
371
|
if (fs.existsSync(projectDir)) {
|
|
338
372
|
const entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
339
373
|
for (const entry of entries) {
|
|
@@ -341,55 +375,192 @@ function finalizeReq(args) {
|
|
|
341
375
|
const m = entry.name.match(/^REQ-(\d+)/i);
|
|
342
376
|
if (m) {
|
|
343
377
|
const num = parseInt(m[1], 10);
|
|
344
|
-
if (num > maxIdNum)
|
|
345
|
-
|
|
378
|
+
if (num > maxIdNum) maxIdNum = num;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 2. 掃描主分支 (若在 Git 環境下)
|
|
385
|
+
if (mainBranch) {
|
|
386
|
+
const gitLs = spawnSync("git", ["ls-tree", `${mainBranch}:project`], { cwd, encoding: "utf8" });
|
|
387
|
+
if (gitLs.status === 0 && gitLs.stdout) {
|
|
388
|
+
for (const line of gitLs.stdout.split("\n")) {
|
|
389
|
+
const parts = line.split("\t");
|
|
390
|
+
if (parts.length > 1) {
|
|
391
|
+
const m = parts[1].trim().match(/^REQ-(\d+)/i);
|
|
392
|
+
if (m) {
|
|
393
|
+
const num = parseInt(m[1], 10);
|
|
394
|
+
if (num > maxIdNum) maxIdNum = num;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 3. 掃描遠端主分支 (若有設定 remote)
|
|
401
|
+
const gitRemoteLs = spawnSync("git", ["ls-tree", `origin/${mainBranch}:project`], { cwd, encoding: "utf8" });
|
|
402
|
+
if (gitRemoteLs.status === 0 && gitRemoteLs.stdout) {
|
|
403
|
+
for (const line of gitRemoteLs.stdout.split("\n")) {
|
|
404
|
+
const parts = line.split("\t");
|
|
405
|
+
if (parts.length > 1) {
|
|
406
|
+
const m = parts[1].trim().match(/^REQ-(\d+)/i);
|
|
407
|
+
if (m) {
|
|
408
|
+
const num = parseInt(m[1], 10);
|
|
409
|
+
if (num > maxIdNum) maxIdNum = num;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return maxIdNum;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function finalizeReq(args) {
|
|
420
|
+
const rawName = getOption(args, "--name") || getOption(args, "--draft") || args[0];
|
|
421
|
+
if (!rawName) {
|
|
422
|
+
fail("Usage: sa-spec finalize --name <name-or-slug> [--no-merge]");
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const slug = slugify(rawName);
|
|
427
|
+
const projectDir = path.join(cwd, "project");
|
|
428
|
+
let draftRelDir = null;
|
|
429
|
+
let draftAbsDir = null;
|
|
430
|
+
let cleanSlug = slug;
|
|
431
|
+
let oldId = null;
|
|
432
|
+
|
|
433
|
+
// 1. 優先尋找草稿目錄 (project/<slug> 或 project/draft-<slug>)
|
|
434
|
+
if (fs.existsSync(path.join(cwd, "project", slug))) {
|
|
435
|
+
draftRelDir = path.join("project", slug);
|
|
436
|
+
draftAbsDir = path.join(cwd, draftRelDir);
|
|
437
|
+
} else if (fs.existsSync(path.join(cwd, "project", `draft-${slug}`))) {
|
|
438
|
+
draftRelDir = path.join("project", `draft-${slug}`);
|
|
439
|
+
draftAbsDir = path.join(cwd, draftRelDir);
|
|
440
|
+
} else if (fs.existsSync(projectDir)) {
|
|
441
|
+
// 2. 尋找既有 REQ-xxx 目錄 (支援序號衝突時的重新定號修復)
|
|
442
|
+
const entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
443
|
+
for (const entry of entries) {
|
|
444
|
+
if (entry.isDirectory()) {
|
|
445
|
+
const m = entry.name.match(/^(REQ-\d+)-(.*)$/i);
|
|
446
|
+
if (m) {
|
|
447
|
+
const entrySlug = slugify(m[2]);
|
|
448
|
+
if (entrySlug === slug || slugify(entry.name) === slug) {
|
|
449
|
+
oldId = m[1].toUpperCase();
|
|
450
|
+
cleanSlug = entrySlug;
|
|
451
|
+
draftRelDir = path.join("project", entry.name);
|
|
452
|
+
draftAbsDir = path.join(cwd, draftRelDir);
|
|
453
|
+
break;
|
|
346
454
|
}
|
|
347
455
|
}
|
|
348
456
|
}
|
|
349
457
|
}
|
|
350
458
|
}
|
|
351
459
|
|
|
460
|
+
if (!draftAbsDir || !fs.existsSync(draftAbsDir)) {
|
|
461
|
+
fail(`找不到草稿或既有需求目錄: project/${slug}`);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 1. 自動向本地與主分支查詢最新已使用的最大 REQ 序號
|
|
466
|
+
const mainBranch = detectMainBranch();
|
|
467
|
+
const currentBranch = getCurrentBranch();
|
|
468
|
+
const maxIdNum = getLatestMaxReqId(projectDir, mainBranch);
|
|
469
|
+
|
|
352
470
|
const nextIdNum = maxIdNum + 1;
|
|
353
471
|
const nextId = `REQ-${String(nextIdNum).padStart(3, "0")}`;
|
|
354
|
-
const finalDirName = `${nextId}-${
|
|
472
|
+
const finalDirName = `${nextId}-${cleanSlug}`;
|
|
355
473
|
const finalRelDir = path.join("project", finalDirName);
|
|
356
474
|
const finalAbsDir = path.join(cwd, finalRelDir);
|
|
357
475
|
|
|
358
|
-
if (fs.existsSync(finalAbsDir)) {
|
|
476
|
+
if (fs.existsSync(finalAbsDir) && finalAbsDir !== draftAbsDir) {
|
|
359
477
|
fail(`目標目錄 ${finalRelDir} 已存在,請確認序號或目錄名稱。`);
|
|
360
478
|
return;
|
|
361
479
|
}
|
|
362
480
|
|
|
363
|
-
// 重命名目錄 (優先嘗試 git mv)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
481
|
+
// 2. 重命名目錄 (優先嘗試 git mv)
|
|
482
|
+
if (draftAbsDir !== finalAbsDir) {
|
|
483
|
+
const gitMv = spawnSync("git", ["mv", draftRelDir, finalRelDir], { cwd, encoding: "utf8" });
|
|
484
|
+
if (gitMv.status === 0) {
|
|
485
|
+
console.log(`[Git] 已將目錄重命名為: ${finalRelDir}`);
|
|
486
|
+
} else {
|
|
487
|
+
fs.renameSync(draftAbsDir, finalAbsDir);
|
|
488
|
+
console.log(`已將目錄重命名為: ${finalRelDir}`);
|
|
489
|
+
}
|
|
370
490
|
}
|
|
371
491
|
|
|
372
|
-
// 替換目錄內檔案的 {{REQ_ID}}
|
|
492
|
+
// 3. 替換目錄內檔案的 {{REQ_ID}} 佔位符及舊序號
|
|
373
493
|
const files = listFilesRecursive(finalAbsDir);
|
|
374
494
|
for (const filePath of files) {
|
|
375
495
|
try {
|
|
376
|
-
|
|
496
|
+
let content = fs.readFileSync(filePath, "utf8");
|
|
497
|
+
let changed = false;
|
|
377
498
|
if (content.includes("{{REQ_ID}}")) {
|
|
378
|
-
|
|
379
|
-
|
|
499
|
+
content = content.split("{{REQ_ID}}").join(nextId);
|
|
500
|
+
changed = true;
|
|
501
|
+
}
|
|
502
|
+
if (oldId && content.includes(oldId)) {
|
|
503
|
+
content = content.split(oldId).join(nextId);
|
|
504
|
+
changed = true;
|
|
505
|
+
}
|
|
506
|
+
if (changed) {
|
|
507
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
380
508
|
}
|
|
381
509
|
} catch (_) {
|
|
382
510
|
// 略過無法以文字讀取之檔案
|
|
383
511
|
}
|
|
384
512
|
}
|
|
385
513
|
|
|
386
|
-
// 自動重新整理 index.md
|
|
514
|
+
// 4. 自動重新整理 index.md
|
|
387
515
|
reindexProject();
|
|
388
516
|
|
|
389
517
|
console.log(`\n 需求已成功定號歸檔為: ${nextId} (${finalDirName})`);
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
518
|
+
|
|
519
|
+
// 5. 自動提交與自動合併回主分支 (Git-Flow 閉環)
|
|
520
|
+
const shouldAutoMerge = !args.includes("--no-merge");
|
|
521
|
+
if (currentBranch && mainBranch && currentBranch !== mainBranch) {
|
|
522
|
+
console.log(`[Git] 正在為分支 ${currentBranch} 建立歸檔 commit...`);
|
|
523
|
+
spawnSync("git", ["add", "project/"], { cwd, encoding: "utf8" });
|
|
524
|
+
const commitRes = spawnSync("git", ["commit", "-m", `docs(${nextId}): ${name} - 規格確認歸檔`], { cwd, encoding: "utf8" });
|
|
525
|
+
if (commitRes.status === 0) {
|
|
526
|
+
console.log(`[Git] 分支 ${currentBranch} 提交完成: docs(${nextId}): ${name} - 規格確認歸檔`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (shouldAutoMerge) {
|
|
530
|
+
console.log(`[Git] 自動切換至主分支 ${mainBranch} 並合併 ${currentBranch}...`);
|
|
531
|
+
const checkoutRes = spawnSync("git", ["checkout", mainBranch], { cwd, encoding: "utf8" });
|
|
532
|
+
if (checkoutRes.status !== 0) {
|
|
533
|
+
console.error(`[Git 警告] 切換至主分支 ${mainBranch} 失敗: ${(checkoutRes.stderr || "").trim()}`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const mergeRes = spawnSync("git", ["merge", currentBranch, "--no-edit"], { cwd, encoding: "utf8" });
|
|
538
|
+
if (mergeRes.status === 0) {
|
|
539
|
+
console.log(`[Git] 已成功將 ${currentBranch} 合併至 ${mainBranch}!`);
|
|
540
|
+
// 主分支重新整理索引確保排序與衝突檢查無誤
|
|
541
|
+
reindexProject();
|
|
542
|
+
const statusRes = spawnSync("git", ["status", "--porcelain", "project/index.md"], { cwd, encoding: "utf8" });
|
|
543
|
+
if (statusRes.stdout && statusRes.stdout.trim()) {
|
|
544
|
+
spawnSync("git", ["add", "project/index.md"], { cwd, encoding: "utf8" });
|
|
545
|
+
spawnSync("git", ["commit", "-m", `chore: update project/index.md for ${nextId}`], { cwd, encoding: "utf8" });
|
|
546
|
+
}
|
|
547
|
+
console.log(` 全部歸檔流程完成!已自動領取新號 ${nextId} 並自動合併回主分支 ${mainBranch}。`);
|
|
548
|
+
} else {
|
|
549
|
+
console.error(`[Git 警告] 合併至 ${mainBranch} 發生衝突,請手動確認衝突狀況:\n${(mergeRes.stderr || mergeRes.stdout || "").trim()}`);
|
|
550
|
+
}
|
|
551
|
+
} else {
|
|
552
|
+
console.log(`提示:已跳過自動合併 (--no-merge)。請在完成後手動執行:`);
|
|
553
|
+
console.log(` git checkout ${mainBranch} && git merge ${currentBranch}`);
|
|
554
|
+
}
|
|
555
|
+
} else if (currentBranch && currentBranch === mainBranch) {
|
|
556
|
+
spawnSync("git", ["add", "project/"], { cwd, encoding: "utf8" });
|
|
557
|
+
spawnSync("git", ["commit", "-m", `docs(${nextId}): ${name} - 規格確認歸檔`], { cwd, encoding: "utf8" });
|
|
558
|
+
console.log(`[Git] 已直接於主分支 ${mainBranch} 建立提交完成。`);
|
|
559
|
+
} else {
|
|
560
|
+
console.log(`建議接下來執行:`);
|
|
561
|
+
console.log(` git add project/`);
|
|
562
|
+
console.log(` git commit -m "docs(${nextId}): ${name} - 規格確認歸檔"`);
|
|
563
|
+
}
|
|
393
564
|
}
|
|
394
565
|
|
|
395
566
|
function reindexProject() {
|
|
@@ -459,6 +630,20 @@ function reindexProject() {
|
|
|
459
630
|
// 依數字循序排序
|
|
460
631
|
rows.sort((a, b) => a.num - b.num);
|
|
461
632
|
|
|
633
|
+
// 檢查是否有重複編號衝突
|
|
634
|
+
const idCountMap = new Map();
|
|
635
|
+
for (const r of rows) {
|
|
636
|
+
idCountMap.set(r.id, (idCountMap.get(r.id) || 0) + 1);
|
|
637
|
+
}
|
|
638
|
+
const duplicateIds = Array.from(idCountMap.entries()).filter(([_, count]) => count > 1);
|
|
639
|
+
if (duplicateIds.length > 0) {
|
|
640
|
+
console.warn("\n[警告] 發現重複需求編號衝突:");
|
|
641
|
+
for (const [dupId, count] of duplicateIds) {
|
|
642
|
+
console.warn(` - ${dupId}: 共有 ${count} 個需求目錄共用此編號!`);
|
|
643
|
+
}
|
|
644
|
+
console.warn(" 修復建議:請將衝突目錄之一改回草稿名稱後,重新執行 sa-spec finalize 或 scc 重新領號。\n");
|
|
645
|
+
}
|
|
646
|
+
|
|
462
647
|
const header = `# 需求索引\n\n| 編號 | 標題 | 敘述 | 狀態 | 建立時間 | 更新時間 |\n|---|---|---|---|---|---|\n`;
|
|
463
648
|
const tableRows = rows
|
|
464
649
|
.map((r) => `| ${r.id} | ${r.title} | ${r.description} | ${r.status} | ${r.createdAt} | ${r.updatedAt} |`)
|
|
@@ -736,7 +921,7 @@ function showHelp() {
|
|
|
736
921
|
Usage:
|
|
737
922
|
sa-spec init [--full] [--lang zh-TW|en|zh-CN|ja|ko]
|
|
738
923
|
sa-spec new [--id REQ-001] --name login [--branch]
|
|
739
|
-
sa-spec finalize --name login
|
|
924
|
+
sa-spec finalize --name login [--no-merge]
|
|
740
925
|
sa-spec reindex
|
|
741
926
|
sa-spec check
|
|
742
927
|
sa-spec upgrade [--dry-run]
|
package/templates/AGENTS.md
CHANGED
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
| 指令 | 簡寫 | 用途 | 規則 |
|
|
21
21
|
|---|---|---|---|
|
|
22
22
|
| `spec:init` | `sci` | 初始化 | 載入並遵守 `AGENTS.md`、`skills/system-analysis/SKILL.md` 與必要規則,確認 `project/` 結構是否存在。 |
|
|
23
|
-
| `spec:propose` | `scp` | 提出需求 |
|
|
23
|
+
| `spec:propose` | `scp` | 提出需求 | 使用者提出需求後,自動依需求名稱建立並切換至專屬 feature 分支(`feat/<name>`,若已在 feature branch 則沿用),建立草稿目錄(內部使用 `{{REQ_ID}}` 佔位),整理需求摘要、目標、初步流程與待確認事項,不歸檔、不修改既有需求。 |
|
|
24
24
|
| `spec:analyze` | `sca` | 分析與起草 | 先檢查新需求是否與 `project/` 內所有既有需求衝突;無衝突才產出 SRS、UML、OpenAPI、DB Schema 草稿;有衝突則只列出衝突與待釐清問題,待使用者釐清後再次執行 `spec:analyze`(或 `sca`)。 |
|
|
25
|
-
| `spec:commit` | `scc` | 確認歸檔 |
|
|
25
|
+
| `spec:commit` | `scc` | 確認歸檔 | 只把使用者已確認的版本歸檔到該需求資料夾,自動向主分支確認最新可用序號定號(`REQ-xxx`)、替換佔位符,更新 `project/index.md`、`project/system-spec.md` 與 `history.md`,建立本機 Git commit 後,自動切換回主分支並完成合併。 |
|
|
26
26
|
|
|
27
27
|
# 重要限制
|
|
28
28
|
|
|
@@ -43,8 +43,9 @@
|
|
|
43
43
|
- `handoff.md` 只能根據已確認的 SRS、UML、OpenAPI、DB Schema 與限制整理,不得新增未確認的開發假設。
|
|
44
44
|
- 交給 OpenSpec 或其他 AI 開發工具時,必須指定它先閱讀 `handoff.md`,並以 `handoff.md` 作為開發入口與限制摘要;其他規格文件作為詳細來源。
|
|
45
45
|
- `spec:commit` 完成文件歸檔後,必須自動建立本機 Git commit;commit message 以需求編號與需求名稱為主,並加上簡短需求說明,格式為 `docs(REQ-001): 需求名稱 - 簡短需求說明`。
|
|
46
|
+
- `spec:commit` 自動合併到主分支(Git-Flow 閉環):完成 feature 分支 commit 後,若目前處於 feature 分支(`feat/<name>`),AI 必須自動切換回主分支(優先偵測 `main` 或 `master`),拉取/檢查最新狀態並自動合併該 feature 分支 (`git checkout master && git merge feat/<name>`),隨後在主分支重新生成 `project/index.md` 索引,確保主分支規格清單完整且無衝突。
|
|
46
47
|
- `spec:commit` 只有在使用者明確要求推送 remote,或專案設定啟用 auto-push 時,才可執行 `git push`;push 失敗時只回報認證或網路問題,不得回滾已完成的文件歸檔與本機 commit。
|
|
47
|
-
- 多人協作與 Git-Flow
|
|
48
|
+
- 多人協作與 Git-Flow 模式(延遲綁定序號與自動合併):支援多人平行開發。執行 `spec:propose`(或 `scp`)時,若目前處於 master/main 分支,AI 必須自動依需求名稱建立並切換至專屬 feature branch(如 `feat/<name>`),並在 `project/<name>/` 建立草稿目錄,內部使用 `{{REQ_ID}}` 佔位,無需使用者手動敲 CLI;執行 `spec:analyze` 前應先確保取得 master 最新規格以防漏檢衝突;執行 `spec:commit` 歸檔時自動向 master 確認最新可用序號定號,自動將目錄定名為正式 `REQ-xxx`、替換內部佔位符、完成提交並自動合併回 master,避免多人合併時的序號競爭與索引衝突。
|
|
48
49
|
- 文件未經使用者確認,不得標示為 `已審核`。
|
|
49
50
|
- `uml/` 內的 UML 圖不限單一 UML 圖型;`spec:analyze` 必須依需求選擇最能說明流程、互動、狀態或資料關係的 UML 圖,例如活動圖、循序圖、使用案例圖、狀態圖或類別圖,不可為了固定格式硬產生活動圖。**嚴禁將多張圖混在同一個檔案中**,必須在需求資料夾下的 `uml/` 目錄拆分成獨立的 `.puml` 檔案(例如 `uml/main.puml`、`uml/sequence.puml`、`uml/state.puml`),且每個檔案只能包含一組 `@startuml` 與 `@enduml`,以避免 PlantUML 渲染或預覽失敗。
|
|
50
51
|
- 一次最多問 3 個待確認問題,優先詢問會影響範圍、資料、權限、API、DB 或驗收的問題。
|