@house365/comate 0.9.17 → 0.9.19

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.
@@ -0,0 +1,201 @@
1
+ # comate init 目录说明
2
+
3
+ `comate init` 会同时准备两类内容:用户级 skills,以及当前项目的 OpenSpec 和知识库目录。
4
+
5
+ ## 1. 初始化时会发生什么
6
+
7
+ 在项目根目录运行:
8
+
9
+ ```bash
10
+ comate init
11
+ ```
12
+
13
+ 它会:
14
+
15
+ - 将 comate、OpenSpec 和 Superpowers 相关 skills 安装到用户级 skills 目录。
16
+ - 在当前项目创建 `openspec/`。
17
+ - 在当前项目补齐 `docs/` 知识库骨架。
18
+ - 保留已经存在的文件,不覆盖项目已有内容。
19
+
20
+ 项目目录通常会得到以下结构:
21
+
22
+ ```text
23
+ your-project/
24
+ ├── openspec/
25
+ │ ├── config.yaml
26
+ │ ├── changes/
27
+ │ │ └── archive/
28
+ │ └── specs/
29
+ └── docs/
30
+ ├── README.md
31
+ ├── architecture/
32
+ │ └── README.md
33
+ └── knowledge-base/
34
+ ├── README.md
35
+ ├── requirements/
36
+ │ └── _templates/
37
+ │ └── PRD.md
38
+ └── integrations/
39
+ ├── README.md
40
+ └── _TEMPLATE/
41
+ ├── README.md
42
+ └── api-snapshot.md
43
+ ```
44
+
45
+ ## 2. 用户级 skills
46
+
47
+ 默认目录:
48
+
49
+ ```text
50
+ macOS/Linux: ~/.agents/skills/
51
+ Windows: %USERPROFILE%\.agents\skills\
52
+ ```
53
+
54
+ 这里保存可被多个项目共用的 skills。常见目录包括:
55
+
56
+ | 目录 | 用途 |
57
+ | --- | --- |
58
+ | `comate/` | comate 总入口、状态脚本和共享规则 |
59
+ | `comate-open/` | 创建 change,生成初始产物 |
60
+ | `comate-design/` | 技术设计和方案确认 |
61
+ | `comate-build/` | 计划和代码实现 |
62
+ | `comate-verify/` | 测试、验证和分支收尾 |
63
+ | `comate-archive/` | 归档 change 并同步规格 |
64
+ | `comate-fix/` | Bug 或小调整的快速流程 |
65
+ | `comate-gate/` | PRD 门禁和拆分 |
66
+ | `comate-grill/` | 澄清关键决策 |
67
+ | `comate-kb-integration/` | 生成第三方对接档案 |
68
+ | `openspec-*/` | OpenSpec 提供的配套 skills |
69
+
70
+ 通常不需要手动修改这些目录。升级 comate 后,在项目根目录重新运行 `comate init`,即可同步新版 comate skills。
71
+
72
+ ## 3. 项目级 `openspec/`
73
+
74
+ `openspec/` 保存当前项目的变更规格,是项目自己的文件,应纳入版本控制。
75
+
76
+ ### `openspec/config.yaml`
77
+
78
+ OpenSpec 的项目配置文件,记录当前项目使用的规格 schema。通常由初始化命令创建;已有配置会保留,不要随意删除。
79
+
80
+ ### `openspec/changes/`
81
+
82
+ 每个进行中的需求或修复对应一个 change:
83
+
84
+ ```text
85
+ openspec/changes/<change-name>/
86
+ ├── .comate.yaml
87
+ ├── proposal.md
88
+ ├── design.md
89
+ ├── tasks.md
90
+ ├── specs/<capability>/spec.md
91
+ └── .comate/
92
+ ├── handoff/
93
+ └── sources.manifest
94
+ ```
95
+
96
+ 这些文件由 `/comate`、`/comate-fix` 及各阶段 skill 逐步生成:
97
+
98
+ - `.comate.yaml`:comate 当前阶段和验证状态的事实来源。
99
+ - `proposal.md`:为什么做、做什么、范围和非目标。
100
+ - `design.md`:高层设计、方案选择和数据流。
101
+ - `tasks.md`:实施任务清单,完成一项勾选一项。
102
+ - `specs/<capability>/spec.md`:本次变更的增量能力规格。
103
+ - `.comate/handoff/`:阶段之间自动生成的交接上下文。
104
+ - `.comate/sources.manifest`:需求源文件的追踪清单,仅在需求来自文件时出现。
105
+
106
+ `openspec/changes/archive/` 在初始化时就会创建。`<change-name>` 完成并归档后,会移动到:
107
+
108
+ ```text
109
+ openspec/changes/archive/YYYY-MM-DD-<change-name>/
110
+ ```
111
+
112
+ ### `openspec/specs/`
113
+
114
+ 这里保存已经沉淀的长期能力规格:
115
+
116
+ ```text
117
+ openspec/specs/<capability>/spec.md
118
+ ```
119
+
120
+ 归档 change 时,增量规格会合并到对应的主规格中。
121
+
122
+ ## 4. 项目级 `docs/`
123
+
124
+ `docs/` 保存跨多个 change 长期有效的项目文档。初始化只创建骨架,具体内容需要项目团队补充。
125
+
126
+ ### `docs/README.md`
127
+
128
+ 说明 `docs/` 的整体边界,以及架构文档、知识库和 `openspec/` 的分工。一般不需要频繁修改。
129
+
130
+ ### `docs/architecture/`
131
+
132
+ 保存项目架构约束,例如:
133
+
134
+ - 前后端分层和模块依赖;
135
+ - API、DTO、数据库和错误码约定;
136
+ - 命名、日志、测试和部署规范。
137
+
138
+ 初始化时只有占位说明。项目应根据实际技术栈补充正式规则。涉及架构约束的 PRD,门禁会以这里的内容作为对齐依据。
139
+
140
+ ### `docs/knowledge-base/`
141
+
142
+ 保存需求和第三方服务资料。
143
+
144
+ #### `requirements/`
145
+
146
+ 存放原始 PRD。推荐按功能和日期组织:
147
+
148
+ ```text
149
+ docs/knowledge-base/requirements/<YYYYMMDD-功能名>/
150
+ ├── PRD-001-功能名.md
151
+ ├── PRD-001.gate.yaml
152
+ └── PRD-001-REVIEW.md
153
+ ```
154
+
155
+ `_templates/PRD.md` 是新建 PRD 的模板。`.gate.yaml` 和 `-REVIEW.md` 由 `/comate-gate` 生成,不需要手动创建。
156
+
157
+ #### `integrations/`
158
+
159
+ 保存第三方服务的长期对接档案:
160
+
161
+ ```text
162
+ docs/knowledge-base/integrations/<service-id>/
163
+ ├── README.md
164
+ └── api-snapshot.md
165
+ ```
166
+
167
+ `_TEMPLATE/` 只存模板,不代表真实服务。接入新服务时,复制模板或使用 `/comate-kb-integration` 生成档案,并维护 `integrations/README.md` 中的服务索引。
168
+
169
+ ## 5. 后续流程会新增的目录
170
+
171
+ 以下目录不是 `comate init` 必然立即创建的,而是在对应阶段产生:
172
+
173
+ ```text
174
+ docs/superpowers/
175
+ ├── specs/ # design 阶段的设计文档
176
+ ├── plans/ # build 阶段的实施计划
177
+ └── reports/ # verify 阶段的验证报告
178
+ ```
179
+
180
+ 如果项目暂时没有进行中的 change,这些目录不存在是正常的。
181
+
182
+ ## 6. 哪些内容应提交到 Git
183
+
184
+ 建议提交项目级内容:
185
+
186
+ - `openspec/`
187
+ - `docs/architecture/`
188
+ - `docs/knowledge-base/`
189
+ - 已生成的 `docs/superpowers/`
190
+
191
+ 用户级 `~/.agents/skills/` 或 `%USERPROFILE%\.agents\skills\` 属于本机环境,不放进项目仓库。
192
+
193
+ ## 7. 重跑 init
194
+
195
+ `comate init` 是幂等的:
196
+
197
+ - 已安装的依赖和 skills 会跳过或复用;
198
+ - 已存在的 `openspec/` 和 `docs/` 文件会保留;
199
+ - 缺少的目录或模板文件会补齐。
200
+
201
+ 因此升级 comate 后,可以安全地在每个项目根目录重新运行一次 `comate init`。
@@ -595,7 +595,8 @@ bash "$COMATE" doctor
595
595
  | `gate <prd> review` | 建/刷新 gate.yaml,钉当前 PRD sha256,回显评审报告路径。PRD 变了则状态重置为 `reviewing` |
596
596
  | `gate <prd> set <field> <value>` | 写字段:`score` / `blocking_open` / `gate_status` 等 |
597
597
  | `gate <prd> pass [--tbd]` | 校验评分、拆分映射和报告。普通 pass 要求 `blocking_open=0`;`--tbd` 要求至少一个经用户接受的未解决阻断项 |
598
- | `gate <prd> spawned <change>` | 记录据此 PRD 扇出的 change(逗号拼接) |
598
+ | `gate <prd> spawned <change>` | 校验 change 属于确认映射、gate 未 stale、open 骨架与 PRD 源追踪完整后,记录据此 PRD 扇出的 change(逗号拼接,幂等) |
599
+ | `gate <prd> fanout-status [--json]` | 对比 `changes_map` 与 `spawned_changes`,输出 expected/spawned/complete/missing/invalid,用于多 change 中断恢复与完整性校验 |
599
600
  | `gate <prd> status [--json]` | 查状态;哈希漂移时自动判 `stale`(不改文件) |
600
601
  | `gate <prd> check` | comate-open 建 change 前的前置守卫。PASS 要求 gate 存在、状态 ∈ {passed, passed-with-tbd}、哈希未漂移;否则退出码 1 阻塞 |
601
602
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@house365/comate",
3
- "version": "0.9.17",
3
+ "version": "0.9.19",
4
4
  "description": "comate —— SDD (规约驱动开发) 的 AI 开发编排器(Agent Skills)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,6 +21,7 @@
21
21
  # gate <prd> set <field> <value> — write score | blocking_open | gate_status | …
22
22
  # gate <prd> pass [--tbd] — pass clean gate or explicitly accepted TBD blockers
23
23
  # gate <prd> spawned <change> — record a change fanned out from this PRD
24
+ # gate <prd> fanout-status [--json] — report mapped/spawned fanout progress
24
25
  # gate <prd> status [--json] — print gate status (computes stale on hash drift)
25
26
  # gate <prd> check — precondition guard before spawning a change
26
27
  # archive <name> [--dry-run] — sync delta specs + annotate + move + mark archived
@@ -62,6 +63,7 @@ Usage: comate.sh <command> ...
62
63
  gate <prd> set <field> <value> score | blocking_open | gate_status | …
63
64
  gate <prd> pass [--tbd] pass: zero blockers; --tbd: accepted blockers
64
65
  gate <prd> spawned <change> record a change fanned out from this PRD
66
+ gate <prd> fanout-status [--json] report mapped/spawned fanout progress
65
67
  gate <prd> status [--json]
66
68
  gate <prd> check precondition guard before spawning a change
67
69
  archive <name> [--dry-run]
@@ -1471,10 +1473,52 @@ gate_set() {
1471
1473
  [ -n "$field" ] || die "$COMATE_USAGE" "Usage: comate.sh gate <prd> set <field> <value>"
1472
1474
  local gate; gate=$(gate_path_for "$prd")
1473
1475
  [ -f "$gate" ] || die "$COMATE_CORRUPT" "ERROR: gate not found (run 'gate review' first): $gate"
1476
+ if [ "$field" = "changes_map" ]; then
1477
+ gate_validate_changes_map "$value"
1478
+ local current_map current_status
1479
+ current_map=$(yaml_get "$gate" changes_map)
1480
+ current_status=$(yaml_get "$gate" gate_status)
1481
+ case "$current_status" in
1482
+ passed|passed-with-tbd)
1483
+ [ "$value" = "$current_map" ] || \
1484
+ die "$COMATE_BLOCK" "BLOCK: changes_map is immutable after gate pass; re-run gate review before remapping"
1485
+ ;;
1486
+ esac
1487
+ fi
1474
1488
  gate_yaml_set "$gate" "$field" "$value"
1475
1489
  log_pass "[GATE] set $field=$value"
1476
1490
  }
1477
1491
 
1492
+ # gate_validate_changes_map <changes_map> — reject malformed entries and
1493
+ # duplicate names when the user-confirmed mapping is first persisted.
1494
+ gate_validate_changes_map() {
1495
+ local map="$1" rest entry name items seen=""
1496
+ [ -n "$map" ] && [ "$map" != "null" ] || \
1497
+ die "$COMATE_USAGE" "ERROR: changes_map cannot be empty"
1498
+ case "$map" in
1499
+ \;*|*\;\;*|*\;) die "$COMATE_USAGE" "ERROR: changes_map contains an empty entry" ;;
1500
+ esac
1501
+ rest="$map"
1502
+ while [ -n "$rest" ]; do
1503
+ case "$rest" in
1504
+ *\;*) entry="${rest%%;*}"; rest="${rest#*;}" ;;
1505
+ *) entry="$rest"; rest="" ;;
1506
+ esac
1507
+ name="${entry%%:*}"
1508
+ items="${entry#*:}"
1509
+ [ -n "$name" ] && [ "$name" != "$entry" ] || \
1510
+ die "$COMATE_USAGE" "ERROR: malformed changes_map entry '$entry'"
1511
+ [ -n "$(printf '%s' "$items" | tr -d '[:space:]')" ] || \
1512
+ die "$COMATE_USAGE" "ERROR: empty function mapping for change '$name'"
1513
+ validate_change_name "$name"
1514
+ case ",$seen," in
1515
+ *",$name,"*) die "$COMATE_USAGE" "ERROR: duplicate change '$name' in changes_map" ;;
1516
+ esac
1517
+ [ -n "$seen" ] && seen="$seen,"
1518
+ seen="$seen$name"
1519
+ done
1520
+ }
1521
+
1478
1522
  # gate <prd> pass [--tbd] — flip to passed/passed-with-tbd. Normal pass requires
1479
1523
  # zero blockers; --tbd requires at least one explicitly accepted blocker.
1480
1524
  gate_pass() {
@@ -1501,6 +1545,7 @@ gate_pass() {
1501
1545
  if [ -z "$changes_map" ] || [ "$changes_map" = "null" ]; then
1502
1546
  die "$COMATE_BLOCK" "BLOCK: changes_map is required before passing the gate"
1503
1547
  fi
1548
+ gate_validate_changes_map "$changes_map"
1504
1549
  local report; report=$(yaml_get "$gate" review_report)
1505
1550
  if [ -z "$report" ] || [ "$report" = "null" ] || ! file_nonempty "$report"; then
1506
1551
  die "$COMATE_BLOCK" "BLOCK: review report missing or empty ($report) — the gate report is mandatory"
@@ -1524,10 +1569,15 @@ gate_spawned() {
1524
1569
  validate_change_name "$change"
1525
1570
  local gate; gate=$(gate_path_for "$prd")
1526
1571
  [ -f "$gate" ] || die "$COMATE_CORRUPT" "ERROR: gate not found: $gate"
1572
+ local changes_map; changes_map=$(yaml_get "$gate" changes_map)
1573
+ gate_change_is_mapped "$changes_map" "$change" || \
1574
+ die "$COMATE_BLOCK" "BLOCK: change '$change' is not present in changes_map"
1527
1575
  local cur; cur=$(yaml_get "$gate" spawned_changes)
1528
1576
  case ",$cur," in
1529
1577
  *",$change,"*) log_info "[GATE] $change already recorded" ; return 0 ;;
1530
1578
  esac
1579
+ gate_check "$prd" >/dev/null
1580
+ gate_require_skeleton "$prd" "$change"
1531
1581
  if [ -z "$cur" ] || [ "$cur" = "null" ]; then
1532
1582
  gate_yaml_set "$gate" spawned_changes "$change"
1533
1583
  else
@@ -1536,6 +1586,118 @@ gate_spawned() {
1536
1586
  log_pass "[GATE] recorded spawned change: $change"
1537
1587
  }
1538
1588
 
1589
+ # gate_require_skeleton <prd> <change> — prove that comate-open finished the
1590
+ # gate skeleton before it is counted as spawned.
1591
+ gate_require_skeleton() {
1592
+ local prd="$1" change="$2" dir yaml manifest tracked_prd f
1593
+ dir=$(comate_change_dir "$change")
1594
+ for f in .openspec.yaml proposal.md design.md tasks.md .comate.yaml; do
1595
+ file_nonempty "$dir/$f" || \
1596
+ die "$COMATE_BLOCK" "BLOCK: change '$change' skeleton incomplete: missing or empty $dir/$f"
1597
+ done
1598
+ yaml="$dir/.comate.yaml"
1599
+ validate_flat_yaml "$yaml"
1600
+ [ "$(yaml_get "$yaml" phase)" = "open" ] || \
1601
+ die "$COMATE_BLOCK" "BLOCK: change '$change' skeleton must remain in phase=open"
1602
+ [ "$(yaml_get "$yaml" source_kind)" = "file" ] || \
1603
+ die "$COMATE_BLOCK" "BLOCK: change '$change' skeleton has not tracked its PRD source"
1604
+ manifest=$(yaml_get "$yaml" source_manifest)
1605
+ [ -n "$manifest" ] && [ "$manifest" != "null" ] && [ -f "$manifest" ] || \
1606
+ die "$COMATE_BLOCK" "BLOCK: change '$change' source manifest is missing"
1607
+ tracked_prd=$(validate_kb_path "$prd") || \
1608
+ die "$COMATE_BLOCK" "BLOCK: PRD source path is outside the knowledge base: $prd"
1609
+ local record_ok=0 line record_path="" record_hash=""
1610
+ while IFS= read -r line || [ -n "$line" ]; do
1611
+ case "$line" in
1612
+ path:*) record_path="${line#path:}"; record_hash="" ;;
1613
+ sha256:*)
1614
+ record_hash="${line#sha256:}"
1615
+ if [ "$record_path" = "$tracked_prd" ] && [ -n "$record_hash" ]; then record_ok=1; fi
1616
+ ;;
1617
+ ---|"") record_path=""; record_hash="" ;;
1618
+ esac
1619
+ done < "$manifest"
1620
+ [ "$record_ok" -eq 1 ] || \
1621
+ die "$COMATE_BLOCK" "BLOCK: change '$change' source manifest lacks a complete path/sha256 record for $tracked_prd"
1622
+ local drift rc=0
1623
+ drift=$(sources_drift "$manifest") || rc=$?
1624
+ [ "$rc" -eq 0 ] || die "$COMATE_BLOCK" "BLOCK: change '$change' source evidence is stale: $drift"
1625
+ }
1626
+
1627
+ # gate_change_names <changes_map> — emit mapped change names, one per line.
1628
+ # The persisted format is `name:function item,...;name:function item,...`.
1629
+ gate_change_names() {
1630
+ local map="$1" entry name
1631
+ [ -n "$map" ] && [ "$map" != "null" ] || return 0
1632
+ gate_validate_changes_map "$map"
1633
+ while [ -n "$map" ]; do
1634
+ case "$map" in
1635
+ *\;*) entry="${map%%;*}"; map="${map#*;}" ;;
1636
+ *) entry="$map"; map="" ;;
1637
+ esac
1638
+ name="${entry%%:*}"
1639
+ [ -n "$name" ] || die "$COMATE_USAGE" "ERROR: malformed changes_map entry '$entry'"
1640
+ [ "$name" != "$entry" ] || die "$COMATE_USAGE" "ERROR: malformed changes_map entry '$entry'"
1641
+ validate_change_name "$name"
1642
+ printf '%s\n' "$name"
1643
+ done
1644
+ }
1645
+
1646
+ # gate_change_is_mapped <changes_map> <change> — membership test for a mapped name.
1647
+ gate_change_is_mapped() {
1648
+ local map="$1" wanted="$2" name
1649
+ gate_validate_changes_map "$map"
1650
+ while IFS= read -r name; do
1651
+ [ "$name" = "$wanted" ] && return 0
1652
+ done <<EOF
1653
+ $(gate_change_names "$map")
1654
+ EOF
1655
+ return 1
1656
+ }
1657
+
1658
+ # gate_fanout_status <prd> [--json] — calculate persisted fanout progress.
1659
+ # This is deliberately derived from changes_map and spawned_changes so an
1660
+ # interrupted run can resume from the missing names instead of relying on
1661
+ # conversation/task state.
1662
+ gate_fanout_status() {
1663
+ local prd; prd=$(require_prd "$1")
1664
+ local json="${2:-}" gate; gate=$(gate_path_for "$prd")
1665
+ [ -f "$gate" ] || die "$COMATE_CORRUPT" "ERROR: gate not found: $gate"
1666
+ local map spawned name expected=0 count=0 missing="" invalid=""
1667
+ map=$(yaml_get "$gate" changes_map); spawned=$(yaml_get "$gate" spawned_changes)
1668
+ gate_validate_changes_map "$map"
1669
+ while IFS= read -r name; do
1670
+ [ -n "$name" ] || continue
1671
+ expected=$((expected + 1))
1672
+ case ",$spawned," in
1673
+ *",$name,"*) count=$((count + 1)) ;;
1674
+ *) [ -n "$missing" ] && missing="$missing,"; missing="$missing$name" ;;
1675
+ esac
1676
+ done <<EOF
1677
+ $(gate_change_names "$map")
1678
+ EOF
1679
+ if [ -n "$spawned" ] && [ "$spawned" != "null" ]; then
1680
+ local rest="$spawned" item
1681
+ while [ -n "$rest" ]; do
1682
+ case "$rest" in
1683
+ *,*) item="${rest%%,*}"; rest="${rest#*,}" ;;
1684
+ *) item="$rest"; rest="" ;;
1685
+ esac
1686
+ gate_change_is_mapped "$map" "$item" || { [ -n "$invalid" ] && invalid="$invalid,"; invalid="$invalid$item"; }
1687
+ done
1688
+ fi
1689
+ local complete=false
1690
+ [ "$expected" -gt 0 ] && [ "$count" -eq "$expected" ] && [ -z "$invalid" ] && complete=true
1691
+ if [ "$json" = "--json" ]; then
1692
+ printf '{"expected":%s,"spawned":%s,"complete":%s,"missing":"%s","invalid":"%s"}\n' \
1693
+ "$expected" "$count" "$complete" "$(json_escape "$missing")" "$(json_escape "$invalid")"
1694
+ else
1695
+ [ -z "$missing" ] && missing=none
1696
+ [ -z "$invalid" ] && invalid=none
1697
+ echo "fanout: expected=$expected spawned=$count complete=$complete missing=$missing invalid=$invalid"
1698
+ fi
1699
+ }
1700
+
1539
1701
  # _gate_is_stale <gate> <prd> — true (0) if the recorded hash no longer matches
1540
1702
  # the PRD on disk.
1541
1703
  _gate_is_stale() {
@@ -1956,6 +2118,7 @@ case "$CMD" in
1956
2118
  set) gate_set "$PRD" "${1:-}" "${2:-}" ;;
1957
2119
  pass) gate_pass "$PRD" "${1:-}" ;;
1958
2120
  spawned) gate_spawned "$PRD" "${1:-}" ;;
2121
+ fanout-status) gate_fanout_status "$PRD" "${1:-}" ;;
1959
2122
  status) gate_status "$PRD" "${1:-}" ;;
1960
2123
  check) gate_check "$PRD" ;;
1961
2124
  *) die "$COMATE_USAGE" "Unknown gate subcommand: $SUB" ;;
@@ -565,6 +565,17 @@ seed_prd() {
565
565
  echo "docs/knowledge-base/requirements/test/PRD-001-demo.md"
566
566
  }
567
567
 
568
+ seed_gate_skeleton() {
569
+ local prd="$1" change="$2" d="openspec/changes/$2"
570
+ mkdir -p "$d"
571
+ printf 'schema: specs-driven\n' > "$d/.openspec.yaml"
572
+ printf '# proposal\n' > "$d/proposal.md"
573
+ printf '# design\n' > "$d/design.md"
574
+ printf -- '- [ ] task\n' > "$d/tasks.md"
575
+ comate state "$change" init full >/dev/null
576
+ comate sources "$change" track "$prd" >/dev/null
577
+ }
578
+
568
579
  @test "gate review creates gate.yaml next to PRD" {
569
580
  local prd; prd=$(seed_prd)
570
581
  run comate gate "$prd" review
@@ -643,6 +654,71 @@ seed_prd() {
643
654
  [[ "$output" == *"changes_map"* ]]
644
655
  }
645
656
 
657
+ @test "gate rejects changes_map entries without a function mapping" {
658
+ local prd; prd=$(seed_prd)
659
+ comate gate "$prd" review >/dev/null
660
+ run comate gate "$prd" set changes_map "login-flow"
661
+ [ "$status" -eq 2 ]
662
+ [[ "$output" == *"malformed changes_map"* ]]
663
+ }
664
+
665
+ @test "gate rejects duplicate change names in changes_map" {
666
+ local prd; prd=$(seed_prd)
667
+ comate gate "$prd" review >/dev/null
668
+ run comate gate "$prd" set changes_map "login-flow:功能项1;login-flow:功能项2"
669
+ [ "$status" -eq 2 ]
670
+ [[ "$output" == *"duplicate change"* ]]
671
+ }
672
+
673
+ @test "gate rejects an empty middle entry in changes_map" {
674
+ local prd; prd=$(seed_prd)
675
+ comate gate "$prd" review >/dev/null
676
+ run comate gate "$prd" set changes_map "login-flow:功能项1;;logout-flow:功能项2"
677
+ [ "$status" -eq 2 ]
678
+ [[ "$output" == *"empty entry"* ]]
679
+ }
680
+
681
+ @test "gate rejects a trailing empty entry in changes_map" {
682
+ local prd; prd=$(seed_prd)
683
+ comate gate "$prd" review >/dev/null
684
+ run comate gate "$prd" set changes_map "login-flow:功能项1;"
685
+ [ "$status" -eq 2 ]
686
+ [[ "$output" == *"empty entry"* ]]
687
+ }
688
+
689
+ @test "gate rejects an empty function mapping" {
690
+ local prd; prd=$(seed_prd)
691
+ comate gate "$prd" review >/dev/null
692
+ run comate gate "$prd" set changes_map "login-flow:"
693
+ [ "$status" -eq 2 ]
694
+ [[ "$output" == *"empty function mapping"* ]]
695
+ }
696
+
697
+ @test "gate rejects an all-whitespace function mapping" {
698
+ local prd; prd=$(seed_prd)
699
+ comate gate "$prd" review >/dev/null
700
+ run comate gate "$prd" set changes_map "login-flow: "
701
+ [ "$status" -eq 2 ]
702
+ [[ "$output" == *"empty function mapping"* ]]
703
+ }
704
+
705
+ @test "gate keeps a passed changes_map immutable except for idempotent writes" {
706
+ local prd; prd=$(seed_prd)
707
+ comate gate "$prd" review >/dev/null
708
+ comate gate "$prd" set score 90 >/dev/null
709
+ comate gate "$prd" set blocking_open 0 >/dev/null
710
+ comate gate "$prd" set changes_map "a:功能项1;b:功能项2;c:功能项3;d:功能项4" >/dev/null
711
+ printf '# 报告\n' > "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
712
+ comate gate "$prd" pass >/dev/null
713
+ run comate gate "$prd" set changes_map "a:功能项1;b:功能项2;c:功能项3;d:功能项4"
714
+ [ "$status" -eq 0 ]
715
+ run comate gate "$prd" set changes_map "a:功能项1"
716
+ [ "$status" -eq 1 ]
717
+ [[ "$output" == *"immutable after gate pass"* ]]
718
+ run comate gate "$prd" fanout-status --json
719
+ [[ "$output" == *'"expected":4'* ]]
720
+ }
721
+
646
722
  @test "gate pass --tbd requires unresolved blockers" {
647
723
  local prd; prd=$(seed_prd)
648
724
  comate gate "$prd" review >/dev/null
@@ -727,6 +803,7 @@ seed_prd() {
727
803
  printf '# 报告\n' > \
728
804
  "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
729
805
  comate gate "$prd" pass >/dev/null
806
+ seed_gate_skeleton "$prd" login-flow
730
807
  run comate gate "$prd" spawned login-flow
731
808
  [ "$status" -eq 0 ]
732
809
  local gate="docs/knowledge-base/requirements/test/PRD-001.gate.yaml"
@@ -743,6 +820,8 @@ seed_prd() {
743
820
  printf '# 报告\n' > \
744
821
  "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
745
822
  comate gate "$prd" pass >/dev/null
823
+ seed_gate_skeleton "$prd" login-flow
824
+ seed_gate_skeleton "$prd" logout-flow
746
825
  comate gate "$prd" spawned login-flow >/dev/null
747
826
  comate gate "$prd" spawned logout-flow >/dev/null
748
827
  local gate="docs/knowledge-base/requirements/test/PRD-001.gate.yaml"
@@ -751,6 +830,131 @@ seed_prd() {
751
830
  [[ "$sc" == *"logout-flow"* ]]
752
831
  }
753
832
 
833
+ @test "gate spawned remains idempotent after an already recorded change leaves the active directory" {
834
+ local prd; prd=$(seed_prd)
835
+ comate gate "$prd" review >/dev/null
836
+ comate gate "$prd" set score 90 >/dev/null
837
+ comate gate "$prd" set blocking_open 0 >/dev/null
838
+ comate gate "$prd" set changes_map "login-flow:功能项1" >/dev/null
839
+ printf '# 报告\n' > "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
840
+ comate gate "$prd" pass >/dev/null
841
+ seed_gate_skeleton "$prd" login-flow
842
+ comate gate "$prd" spawned login-flow >/dev/null
843
+ mkdir -p openspec/changes/archive
844
+ mv openspec/changes/login-flow openspec/changes/archive/2026-08-28-login-flow
845
+ run comate gate "$prd" spawned login-flow
846
+ [ "$status" -eq 0 ]
847
+ [[ "$output" == *"already recorded"* ]]
848
+ }
849
+
850
+ @test "gate fanout status reports missing changes instead of treating one of four as complete" {
851
+ local prd; prd=$(seed_prd)
852
+ comate gate "$prd" review >/dev/null
853
+ comate gate "$prd" set score 90 >/dev/null
854
+ comate gate "$prd" set blocking_open 0 >/dev/null
855
+ comate gate "$prd" set changes_map "login-flow:功能项1;logout-flow:功能项2;ops:功能项3;report:功能项4" >/dev/null
856
+ printf '# 报告\n' > "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
857
+ comate gate "$prd" pass >/dev/null
858
+ seed_gate_skeleton "$prd" login-flow
859
+ comate gate "$prd" spawned login-flow >/dev/null
860
+ run comate gate "$prd" fanout-status --json
861
+ [ "$status" -eq 0 ]
862
+ [[ "$output" == *'"expected":4'* ]]
863
+ [[ "$output" == *'"spawned":1'* ]]
864
+ [[ "$output" == *'"complete":false'* ]]
865
+ [[ "$output" == *'logout-flow'* ]]
866
+ }
867
+
868
+ @test "gate spawned rejects a change outside the confirmed map" {
869
+ local prd; prd=$(seed_prd)
870
+ comate gate "$prd" review >/dev/null
871
+ comate gate "$prd" set score 90 >/dev/null
872
+ comate gate "$prd" set blocking_open 0 >/dev/null
873
+ comate gate "$prd" set changes_map "login-flow:功能项1" >/dev/null
874
+ printf '# 报告\n' > "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
875
+ comate gate "$prd" pass >/dev/null
876
+ seed_gate_skeleton "$prd" other-flow
877
+ run comate gate "$prd" spawned other-flow
878
+ [ "$status" -eq 1 ]
879
+ [[ "$output" == *"not present in changes_map"* ]]
880
+ }
881
+
882
+ @test "gate fanout status becomes complete only after every mapped change is spawned" {
883
+ local prd; prd=$(seed_prd)
884
+ comate gate "$prd" review >/dev/null
885
+ comate gate "$prd" set score 90 >/dev/null
886
+ comate gate "$prd" set blocking_open 0 >/dev/null
887
+ comate gate "$prd" set changes_map "login-flow:功能项1;logout-flow:功能项2" >/dev/null
888
+ printf '# 报告\n' > "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
889
+ comate gate "$prd" pass >/dev/null
890
+ seed_gate_skeleton "$prd" login-flow
891
+ seed_gate_skeleton "$prd" logout-flow
892
+ comate gate "$prd" spawned login-flow >/dev/null
893
+ comate gate "$prd" spawned logout-flow >/dev/null
894
+ run comate gate "$prd" fanout-status
895
+ [ "$status" -eq 0 ]
896
+ [[ "$output" == *"complete=true"* ]]
897
+ [[ "$output" == *"missing=none"* ]]
898
+ }
899
+
900
+ @test "gate spawned rejects an incomplete change skeleton" {
901
+ local prd; prd=$(seed_prd)
902
+ comate gate "$prd" review >/dev/null
903
+ comate gate "$prd" set score 90 >/dev/null
904
+ comate gate "$prd" set blocking_open 0 >/dev/null
905
+ comate gate "$prd" set changes_map "login-flow:功能项1" >/dev/null
906
+ printf '# 报告\n' > "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
907
+ comate gate "$prd" pass >/dev/null
908
+ mkdir -p openspec/changes/login-flow
909
+ run comate gate "$prd" spawned login-flow
910
+ [ "$status" -eq 1 ]
911
+ [[ "$output" == *"skeleton incomplete"* ]]
912
+ }
913
+
914
+ @test "gate spawned blocks a new registration after the PRD becomes stale" {
915
+ local prd; prd=$(seed_prd)
916
+ comate gate "$prd" review >/dev/null
917
+ comate gate "$prd" set score 90 >/dev/null
918
+ comate gate "$prd" set blocking_open 0 >/dev/null
919
+ comate gate "$prd" set changes_map "login-flow:功能项1" >/dev/null
920
+ printf '# 报告\n' > "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
921
+ comate gate "$prd" pass >/dev/null
922
+ seed_gate_skeleton "$prd" login-flow
923
+ printf '\nchanged\n' >> "$prd"
924
+ run comate gate "$prd" spawned login-flow
925
+ [ "$status" -eq 1 ]
926
+ [[ "$output" == *"hash mismatch"* ]]
927
+ }
928
+
929
+ @test "gate spawned accepts a normalized PRD path recorded in the source manifest" {
930
+ local prd; prd=$(seed_prd)
931
+ prd="./$prd"
932
+ comate gate "$prd" review >/dev/null
933
+ comate gate "$prd" set score 90 >/dev/null
934
+ comate gate "$prd" set blocking_open 0 >/dev/null
935
+ comate gate "$prd" set changes_map "login-flow:功能项1" >/dev/null
936
+ printf '# 报告\n' > "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
937
+ comate gate "$prd" pass >/dev/null
938
+ seed_gate_skeleton "$prd" login-flow
939
+ run comate gate "$prd" spawned login-flow
940
+ [ "$status" -eq 0 ]
941
+ }
942
+
943
+ @test "gate spawned rejects a truncated PRD source record without sha256" {
944
+ local prd; prd=$(seed_prd)
945
+ comate gate "$prd" review >/dev/null
946
+ comate gate "$prd" set score 90 >/dev/null
947
+ comate gate "$prd" set blocking_open 0 >/dev/null
948
+ comate gate "$prd" set changes_map "login-flow:功能项1" >/dev/null
949
+ printf '# 报告\n' > "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
950
+ comate gate "$prd" pass >/dev/null
951
+ seed_gate_skeleton "$prd" login-flow
952
+ printf 'path:%s\n' "$prd" > openspec/changes/login-flow/.comate/sources.manifest
953
+ run comate gate "$prd" spawned login-flow
954
+ [ "$status" -eq 1 ]
955
+ [[ "$output" == *"complete path/sha256 record"* ]]
956
+ }
957
+
754
958
  @test "gate status returns none when gate does not exist" {
755
959
  mkdir -p docs/knowledge-base/requirements/test
756
960
  run comate gate "docs/knowledge-base/requirements/test/PRD-001-demo.md" status