@aipper/aiws-spec 0.0.10 → 0.0.11
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/package.json +1 -1
- package/templates/workspace/.agents/skills/aiws-change-validate/SKILL.md +1 -1
- package/templates/workspace/.agents/skills/ws-dev/SKILL.md +1 -0
- package/templates/workspace/.agents/skills/ws-plan/SKILL.md +13 -5
- package/templates/workspace/.agents/skills/ws-plan-verify/SKILL.md +41 -0
- package/templates/workspace/AGENTS.md +1 -1
- package/templates/workspace/changes/README.md +6 -0
- package/templates/workspace/changes/templates/proposal.md +14 -6
- package/templates/workspace/changes/templates/tasks.md +4 -0
- package/templates/workspace/manifest.json +1 -0
- package/templates/workspace/tools/ws_change_check.py +352 -5
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ description: 开发(按需求实现并验证;适用于任何需要修改代
|
|
|
10
10
|
建议流程:
|
|
11
11
|
1) 先做 preflight:定位项目根目录,读取 `AI_PROJECT.md` / `REQUIREMENTS.md` / `AI_WORKSPACE.md`,输出约束摘要。
|
|
12
12
|
- 若是中大型任务:建议先用 `$ws-plan` 生成 `plan/` 工件,再进入实现(便于可回放与对齐验证入口)。
|
|
13
|
+
- 若已有 `plan/` 工件:先执行 `$ws-plan-verify`;通过后再进入实现(防止计划过长/跑偏)。
|
|
13
14
|
2) 建立变更归因(推荐):
|
|
14
15
|
- 推荐一键:`aiws change start <change-id> --hooks`(切分支 + 初始化变更工件 + 启用 hooks)
|
|
15
16
|
- superproject + submodule(推荐):`aiws change start <change-id> --hooks --worktree --submodules`(创建独立 worktree;当前目录分支保持不变)
|
|
@@ -9,21 +9,27 @@ description: 规划(生成可落盘 plan/ 工件;供 ws-dev 执行)
|
|
|
9
9
|
- 对齐真值文件(`AI_PROJECT.md` / `REQUIREMENTS.md` / `AI_WORKSPACE.md`)
|
|
10
10
|
- 为当前任务生成一份可追踪的执行计划文件:`plan/<timestamp>-<slug>.md`
|
|
11
11
|
- 计划必须包含可复现验证命令(优先引用 `AI_WORKSPACE.md`)
|
|
12
|
+
- 计划必须包含“主索引绑定”:`Change_ID` / (`Req_ID` or `Problem_ID`) / `Contract_Row` / `Plan_File` / `Evidence_Path`
|
|
12
13
|
|
|
13
14
|
约束:
|
|
14
15
|
- 不写入任何 secrets(token、账号、内网端点等不得进入 git)
|
|
15
16
|
- 本 skill 只负责“想清楚怎么做 + 落盘计划”,不要直接大规模改动代码
|
|
16
17
|
- 未运行不声称已运行;验证命令要写清“预期结果”
|
|
18
|
+
- 若存在 `changes/<change-id>/proposal.md`,计划与 proposal 的绑定字段必须保持一致(不一致时先修正再继续)
|
|
17
19
|
|
|
18
20
|
执行步骤(建议):
|
|
19
21
|
1) 先运行 `$ws-preflight`(读取真值文件并输出约束摘要)。
|
|
20
22
|
2) 若用户任务描述不清:先问 1-3 个关键澄清问题(不要猜)。
|
|
21
23
|
3) 判断复杂度:`simple / medium / complex`(给出一句理由),并估算步骤数。
|
|
22
|
-
4)
|
|
24
|
+
4) 识别主索引上下文(若存在):
|
|
25
|
+
- 若存在 `changes/<change-id>/proposal.md`:读取其中 `Change_ID` / `Req_ID` / `Problem_ID` / `Contract_Row` / `Evidence_Path`
|
|
26
|
+
- 若缺失关键绑定:先补齐 proposal(至少 `Change_ID`、`Req_ID|Problem_ID`、`Contract_Row`)再继续生成计划
|
|
27
|
+
5) 生成计划文件:
|
|
23
28
|
- 文件名:`plan/YYYY-MM-DD_HH-MM-SS-<slug>.md`(`<slug>` 用 kebab-case;同一任务调整计划时尽量复用同一文件)
|
|
24
29
|
- 若 `plan/` 不存在先创建
|
|
25
30
|
- 必须实际写入到磁盘(不要只在对话里输出);如因权限/策略无法写盘,必须明确说明原因并输出可复制的完整内容
|
|
26
|
-
|
|
31
|
+
6) 计划内容至少包含(不要留空):
|
|
32
|
+
- `Bindings`:`Change_ID` / `Req_ID` / `Problem_ID` / `Contract_Row` / `Plan_File` / `Evidence_Path`
|
|
27
33
|
- `Goal`:要达成什么
|
|
28
34
|
- `Non-goals`:明确不做什么(避免 scope creep)
|
|
29
35
|
- `Scope`:将改动的文件/目录清单(不确定就写 `TBD` 并说明如何确定)
|
|
@@ -31,9 +37,11 @@ description: 规划(生成可落盘 plan/ 工件;供 ws-dev 执行)
|
|
|
31
37
|
- `Verify`:可复现命令 + 期望结果(优先引用 `AI_WORKSPACE.md` 的入口;必要时补充 e2e)
|
|
32
38
|
- `Risks & Rollback`:风险点 + 回滚方案(例如 git 回滚、`aiws rollback`、恢复备份等)
|
|
33
39
|
- `Evidence`:计划文件路径;若创建了变更工件则附 `changes/<change-id>/...`
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
7) 若存在 change proposal:回填并对齐 `proposal.md` 的 `Plan_File`(必要时同步 `Contract_Row` / `Evidence_Path`),保证 plan/proposal 一致。
|
|
41
|
+
8) 运行 `$ws-plan-verify` 作为执行前质量门(计划不过长、不跑偏、验证可复现)。
|
|
42
|
+
9) 若计划涉及“需求/验收”变更:先用 `$ws-req-review` 评审 → 用户确认后再 `$ws-req-change` 落盘(避免需求漂移)。
|
|
43
|
+
10) 多步任务(≥2 步):后续进入实现时,使用 `update_plan` 工具跟踪 `pending → in_progress → completed`。
|
|
36
44
|
|
|
37
45
|
输出要求:
|
|
38
46
|
- `Plan file:` <实际写入的路径>
|
|
39
|
-
- `Next:`
|
|
47
|
+
- `Next:` 推荐下一步(先 `$ws-plan-verify`,通过后再 `$ws-dev`;或 `aiws change start <change-id> --hooks`,superproject + submodule 可用 `--worktree`)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ws-plan-verify
|
|
3
|
+
description: 计划质检(执行前检查计划是否过长/跑偏,并给出最小修正清单)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
用中文输出(命令/路径/代码标识符保持原样不翻译)。
|
|
7
|
+
|
|
8
|
+
目标:
|
|
9
|
+
- 在进入 `$ws-dev` 前,对 `plan/...` 与 `changes/<change-id>/proposal.md` 做一次“硬约束”质检
|
|
10
|
+
- 避免计划过长、步骤不够具体、验证不可复现、与需求合同绑定不一致
|
|
11
|
+
- 失败时只给最小修正项,修完后再进入实现
|
|
12
|
+
|
|
13
|
+
适用时机:
|
|
14
|
+
- 已执行过 `$ws-plan`,且准备开始编码
|
|
15
|
+
- 或用户反馈“计划太长/容易跑偏”,需要先压缩并对齐
|
|
16
|
+
|
|
17
|
+
执行步骤(建议):
|
|
18
|
+
1) 先运行 `$ws-preflight`。
|
|
19
|
+
2) 识别 change 上下文:
|
|
20
|
+
- 优先从当前分支推断 `change/<change-id>`
|
|
21
|
+
- 若无法推断:读取 `changes/*/proposal.md` 中的 `Change_ID`,并让用户确认本次要质检的 change
|
|
22
|
+
3) 运行严格门禁(这是硬约束入口):
|
|
23
|
+
```bash
|
|
24
|
+
if [[ -x "./node_modules/.bin/aiws" ]]; then
|
|
25
|
+
./node_modules/.bin/aiws change validate <change-id> --strict
|
|
26
|
+
elif command -v aiws >/dev/null 2>&1; then
|
|
27
|
+
aiws change validate <change-id> --strict
|
|
28
|
+
else
|
|
29
|
+
npx @aipper/aiws change validate <change-id> --strict
|
|
30
|
+
fi
|
|
31
|
+
```
|
|
32
|
+
4) 若失败:按报错逐条修正 `proposal.md` / `plan/...`,优先级如下:
|
|
33
|
+
- 先修绑定:`Change_ID` / `Req_ID|Problem_ID` / `Contract_Row` / `Plan_File` / `Evidence_Path`
|
|
34
|
+
- 再修计划质量:必需章节、步骤数量、步骤具体性、验证命令与预期
|
|
35
|
+
5) 复跑 strict 校验,直到通过。
|
|
36
|
+
6) 输出“可执行最小计划摘要”(3-8 步),再进入 `$ws-dev`。
|
|
37
|
+
|
|
38
|
+
输出要求:
|
|
39
|
+
- `Quality gate:` pass/fail
|
|
40
|
+
- `Fix list:` 仅保留必须修改项(按阻断级别排序)
|
|
41
|
+
- `Next:` 通过后建议 `$ws-dev`;未通过则继续修正并复跑 strict
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
- 启用本机门禁(推荐):`aiws hooks install .`(或手工:`git config core.hooksPath .githooks`;`git commit`/`git push` 会自动跑 `aiws validate .`)
|
|
11
11
|
- 提交前校验(强制门禁):`aiws validate .`(包含:漂移检测 + `ws_change_check` + `requirements_contract`)
|
|
12
12
|
- Codex(推荐):本仓库内置 repo skills:`.agents/skills/`(可显式 `$ws-dev`,也可隐式套用工作流)
|
|
13
|
-
- Codex skills(常用):`$ws-preflight` / `$ws-plan` / `$ws-dev` / `$ws-review` / `$ws-commit` / `$aiws-init` / `$aiws-validate` / `$aiws-hooks-install` / `$aiws-change-new`
|
|
13
|
+
- Codex skills(常用):`$ws-preflight` / `$ws-plan` / `$ws-plan-verify` / `$ws-dev` / `$ws-review` / `$ws-commit` / `$aiws-init` / `$aiws-validate` / `$aiws-hooks-install` / `$aiws-change-new`
|
|
14
14
|
- Codex CLI(推荐,可选):安装全局 skills:`npx @aipper/aiws codex install-skills`(写入 `~/.codex/skills/` 或 `$CODEX_HOME/skills`)
|
|
15
15
|
- Codex CLI(遗留,可选):安装全局 prompts:`npx @aipper/aiws codex install-prompts`(写入 `~/.codex/prompts/` 或 `$CODEX_HOME/prompts`;prompts 已 deprecated)
|
|
16
16
|
- 不要把敏感信息写入 git:`secrets/test-accounts.json`、`.env*`、token、内网地址等
|
|
@@ -40,6 +40,12 @@ Active change(推荐,团队共享):
|
|
|
40
40
|
- 使用分支名声明当前变更:`change/<change-id>`(也支持 `changes/`、`ws/`、`ws-change/`)
|
|
41
41
|
- 切到该分支后,可省略 `<change-id>` 执行:`aiws change status|next|validate|sync|archive`
|
|
42
42
|
|
|
43
|
+
计划质量门(推荐,执行前):
|
|
44
|
+
- 先生成计划:`$ws-plan`
|
|
45
|
+
- 再执行计划质检:`$ws-plan-verify`
|
|
46
|
+
- 最后进入实现:`$ws-dev`
|
|
47
|
+
- 目标:确保计划具备主索引绑定、步骤不过长、验证命令可复现且有预期结果
|
|
48
|
+
|
|
43
49
|
模板覆盖(可选):
|
|
44
50
|
- 在工作区创建 `changes/templates/` 可覆盖默认模板:
|
|
45
51
|
- `changes/templates/proposal.md`
|
|
@@ -12,12 +12,20 @@
|
|
|
12
12
|
**非目标:**
|
|
13
13
|
- <!-- WS:TODO 填写明确“不做什么”,防止 scope creep -->
|
|
14
14
|
|
|
15
|
-
##
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
## 主索引绑定(强制)
|
|
16
|
+
|
|
17
|
+
- `Change_ID` = {{CHANGE_ID}}
|
|
18
|
+
- 需求交付:`Req_ID` = <!-- WS:TODO (需求交付可填;例如 TOOLING-001B) -->
|
|
19
|
+
- 问题修复:`Problem_ID` = <!-- WS:TODO (问题修复可填;例如 PROB-001) -->
|
|
20
|
+
- `Contract_Row` = <!-- WS:TODO 绑定执行合同中的行 ID,可多项(逗号分隔);例如 Req_ID=TOOLING-001B -->
|
|
21
|
+
- `Plan_File` = <!-- WS:TODO 例如 plan/2026-02-08_15-30-00-xxx.md -->
|
|
22
|
+
- `Evidence_Path` = <!-- WS:TODO 证据路径,可多项(逗号分隔);例如 .agentdocs/tmp/review/codex-review.md -->
|
|
23
|
+
|
|
24
|
+
> 规则:
|
|
25
|
+
> - `Req_ID` 与 `Problem_ID` 至少填写一项。
|
|
26
|
+
> - `Contract_Row` 必须引用 `requirements/requirements-issues.csv` 或 `issues/problem-issues.csv` 中的真实行。
|
|
27
|
+
> - `Plan_File` 对应的计划文件必须存在,且其绑定字段与本文件一致。
|
|
28
|
+
> - `Evidence_Path` 可先声明计划路径,交付前需完成证据落盘。
|
|
21
29
|
|
|
22
30
|
## 现状与问题
|
|
23
31
|
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
- [ ] 0.1 阅读并遵守 `AI_PROJECT.md` / `AI_WORKSPACE.md` / `REQUIREMENTS.md`
|
|
10
10
|
- [ ] 0.2 运行门禁校验:`aiws validate .`(或 `npx -y @aipper/aiws validate .`)
|
|
11
11
|
- [ ] 0.3 若真值文件发生变化(例如你更新了 REQUIREMENTS.md),同步基线:`aiws change sync {{CHANGE_ID}}`
|
|
12
|
+
- [ ] 0.4 在 `changes/{{CHANGE_ID}}/proposal.md` 填写主索引绑定:`Change_ID` / (`Req_ID` or `Problem_ID`) / `Contract_Row` / `Plan_File` / `Evidence_Path`
|
|
13
|
+
- [ ] 0.5 生成 `plan/...` 后,确认计划文件中的绑定字段与 proposal 一致
|
|
14
|
+
- [ ] 0.6 执行计划质检:在 AI 工具运行 `$ws-plan-verify`(或按同等清单手工检查“章节/步骤粒度/验证命令与预期”)
|
|
15
|
+
- [ ] 0.7 严格校验:`aiws change validate {{CHANGE_ID}} --strict`
|
|
12
16
|
|
|
13
17
|
## 1. 需求/问题合同(如适用)
|
|
14
18
|
|
|
@@ -136,6 +136,7 @@
|
|
|
136
136
|
".agents/skills/ws-finish/SKILL.md",
|
|
137
137
|
".agents/skills/ws-migrate/SKILL.md",
|
|
138
138
|
".agents/skills/ws-plan/SKILL.md",
|
|
139
|
+
".agents/skills/ws-plan-verify/SKILL.md",
|
|
139
140
|
".agents/skills/ws-preflight/SKILL.md",
|
|
140
141
|
".agents/skills/ws-req-change/SKILL.md",
|
|
141
142
|
".agents/skills/ws-req-contract-sync/SKILL.md",
|
|
@@ -77,7 +77,7 @@ def has_truth_files(root: Path) -> Tuple[bool, List[str]]:
|
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
def extract_id(label: str, text: str) -> str:
|
|
80
|
-
m = re.search(rf"(?m)^.*{re.escape(label)}.*?[:=]\
|
|
80
|
+
m = re.search(rf"(?m)^.*{re.escape(label)}.*?[:=][ \t]*(.*)$", text)
|
|
81
81
|
if not m:
|
|
82
82
|
return ""
|
|
83
83
|
v = m.group(1).strip()
|
|
@@ -86,6 +86,210 @@ def extract_id(label: str, text: str) -> str:
|
|
|
86
86
|
return v
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def extract_first_id(labels: List[str], text: str) -> str:
|
|
90
|
+
for label in labels:
|
|
91
|
+
v = extract_id(label, text)
|
|
92
|
+
if v:
|
|
93
|
+
return v
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def split_declared_values(value: str) -> List[str]:
|
|
98
|
+
parts = re.split(r"[,;\n]+", value or "")
|
|
99
|
+
out: List[str] = []
|
|
100
|
+
for part in parts:
|
|
101
|
+
v = part.strip().strip("`").strip()
|
|
102
|
+
if v:
|
|
103
|
+
out.append(v)
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def normalize_contract_ref(raw: str) -> Tuple[str, str]:
|
|
108
|
+
token = raw.strip().strip("`").strip()
|
|
109
|
+
token = re.sub(r"^(requirements/requirements-issues\.csv|issues/problem-issues\.csv)\s*[:#]\s*", "", token)
|
|
110
|
+
m = re.match(r"(?i)^(req[_-]?id|problem[_-]?id)\s*=\s*(.+)$", token)
|
|
111
|
+
if m:
|
|
112
|
+
key = m.group(1).lower()
|
|
113
|
+
value = m.group(2).strip()
|
|
114
|
+
if key.startswith("req"):
|
|
115
|
+
return ("Req_ID", value)
|
|
116
|
+
return ("Problem_ID", value)
|
|
117
|
+
if token.upper().startswith("PROB-"):
|
|
118
|
+
return ("Problem_ID", token)
|
|
119
|
+
return ("Any", token)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def normalize_heading_token(raw: str) -> str:
|
|
123
|
+
token = (raw or "").strip().lower()
|
|
124
|
+
token = re.sub(r"<!--.*?-->", "", token)
|
|
125
|
+
token = re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", token)
|
|
126
|
+
return token
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_markdown_section(text: str, aliases: List[str]) -> str:
|
|
130
|
+
lines = text.splitlines()
|
|
131
|
+
alias_tokens: List[str] = []
|
|
132
|
+
for alias in aliases:
|
|
133
|
+
token = normalize_heading_token(alias)
|
|
134
|
+
if token:
|
|
135
|
+
alias_tokens.append(token)
|
|
136
|
+
if not alias_tokens:
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
start = -1
|
|
140
|
+
for i, line in enumerate(lines):
|
|
141
|
+
m = re.match(r"^\s{0,3}(#{2,6})\s+(.+?)\s*$", line)
|
|
142
|
+
if not m:
|
|
143
|
+
continue
|
|
144
|
+
heading_norm = normalize_heading_token(m.group(2))
|
|
145
|
+
if any(alias in heading_norm for alias in alias_tokens):
|
|
146
|
+
start = i + 1
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
if start < 0:
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
end = len(lines)
|
|
153
|
+
for j in range(start, len(lines)):
|
|
154
|
+
if re.match(r"^\s{0,3}#{1,6}\s+.+$", lines[j]):
|
|
155
|
+
end = j
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
return "\n".join(lines[start:end]).strip()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def has_markdown_heading(text: str, aliases: List[str]) -> bool:
|
|
162
|
+
lines = text.splitlines()
|
|
163
|
+
alias_tokens: List[str] = []
|
|
164
|
+
for alias in aliases:
|
|
165
|
+
token = normalize_heading_token(alias)
|
|
166
|
+
if token:
|
|
167
|
+
alias_tokens.append(token)
|
|
168
|
+
if not alias_tokens:
|
|
169
|
+
return False
|
|
170
|
+
for line in lines:
|
|
171
|
+
m = re.match(r"^\s{0,3}(#{2,6})\s+(.+?)\s*$", line)
|
|
172
|
+
if not m:
|
|
173
|
+
continue
|
|
174
|
+
heading_norm = normalize_heading_token(m.group(2))
|
|
175
|
+
if any(alias in heading_norm for alias in alias_tokens):
|
|
176
|
+
return True
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def extract_action_items(text: str) -> List[str]:
|
|
181
|
+
return [
|
|
182
|
+
m.group(1).strip()
|
|
183
|
+
for m in re.finditer(r"(?m)^\s*(?:- \[[ xX]\]|[-*+]|\d+[.)])\s+(.+)$", text or "")
|
|
184
|
+
if m.group(1).strip()
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def has_runnable_command(text: str) -> bool:
|
|
189
|
+
if not text:
|
|
190
|
+
return False
|
|
191
|
+
if re.search(
|
|
192
|
+
r"(?i)\b(aiws|npx|node|npm|pnpm|yarn|python3?|pytest|bash|git|cargo|go|make|uv|\.\/gradlew)\b",
|
|
193
|
+
text,
|
|
194
|
+
):
|
|
195
|
+
return True
|
|
196
|
+
if re.search(
|
|
197
|
+
r"(?mi)^\s*(?:aiws|npx|node|npm|pnpm|yarn|python3?|pytest|bash|git|cargo|go|make|uv|\.\/gradlew)\b",
|
|
198
|
+
text,
|
|
199
|
+
):
|
|
200
|
+
return True
|
|
201
|
+
for inline in re.findall(r"`([^`]+)`", text):
|
|
202
|
+
if re.search(
|
|
203
|
+
r"(?i)\b(aiws|npx|node|npm|pnpm|yarn|python3?|pytest|bash|git|cargo|go|make|uv|\.\/gradlew)\b",
|
|
204
|
+
inline,
|
|
205
|
+
):
|
|
206
|
+
return True
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def is_concrete_plan_item(item: str) -> bool:
|
|
211
|
+
if re.search(r"`[^`]+`", item):
|
|
212
|
+
return True
|
|
213
|
+
if re.search(r"(?i)\b(aiws|npx|node|npm|pnpm|yarn|python3?|pytest|bash|git|cargo|go|make|uv|\.\/gradlew)\b", item):
|
|
214
|
+
return True
|
|
215
|
+
if re.search(r"\b(?:[A-Za-z0-9._-]+/)+[A-Za-z0-9._/-]+\b", item):
|
|
216
|
+
return True
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def validate_plan_quality(plan_text: str, plan_rel: str, strict: bool) -> Tuple[List[str], List[str]]:
|
|
221
|
+
errors: List[str] = []
|
|
222
|
+
warnings: List[str] = []
|
|
223
|
+
|
|
224
|
+
required_sections: List[Tuple[str, List[str]]] = [
|
|
225
|
+
("Bindings", ["bindings", "主索引绑定", "绑定"]),
|
|
226
|
+
("Goal", ["goal", "目标"]),
|
|
227
|
+
("Non-goals", ["nongoals", "非目标"]),
|
|
228
|
+
("Scope", ["scope", "范围", "改动范围"]),
|
|
229
|
+
("Plan", ["plan", "执行步骤", "实施计划", "步骤"]),
|
|
230
|
+
("Verify", ["verify", "verification", "验证"]),
|
|
231
|
+
("Risks & Rollback", ["risksrollback", "riskrollback", "风险与回滚", "回滚"]),
|
|
232
|
+
("Evidence", ["evidence", "证据"]),
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
section_content: Dict[str, str] = {}
|
|
236
|
+
missing_sections: List[str] = []
|
|
237
|
+
empty_sections: List[str] = []
|
|
238
|
+
for section_name, aliases in required_sections:
|
|
239
|
+
exists = has_markdown_heading(plan_text, aliases)
|
|
240
|
+
body = find_markdown_section(plan_text, aliases)
|
|
241
|
+
section_content[section_name] = body
|
|
242
|
+
if not exists:
|
|
243
|
+
missing_sections.append(section_name)
|
|
244
|
+
elif not body.strip():
|
|
245
|
+
empty_sections.append(section_name)
|
|
246
|
+
|
|
247
|
+
if missing_sections:
|
|
248
|
+
(errors if strict else warnings).append(
|
|
249
|
+
f"{plan_rel} missing required sections: {', '.join(missing_sections)}"
|
|
250
|
+
)
|
|
251
|
+
if empty_sections:
|
|
252
|
+
(errors if strict else warnings).append(
|
|
253
|
+
f"{plan_rel} has empty sections: {', '.join(empty_sections)}"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
scope_items = extract_action_items(section_content.get("Scope", ""))
|
|
257
|
+
if scope_items and len(scope_items) > 12:
|
|
258
|
+
(errors if strict else warnings).append(
|
|
259
|
+
f"{plan_rel} scope is too broad ({len(scope_items)} items > 12); split into smaller phases"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
plan_items = extract_action_items(section_content.get("Plan", ""))
|
|
263
|
+
if section_content.get("Plan", "").strip() and not plan_items:
|
|
264
|
+
(errors if strict else warnings).append(
|
|
265
|
+
f"{plan_rel} Plan section has no actionable items (`- [ ]` / bullet / numbered list)"
|
|
266
|
+
)
|
|
267
|
+
if plan_items:
|
|
268
|
+
if len(plan_items) > 8:
|
|
269
|
+
(errors if strict else warnings).append(
|
|
270
|
+
f"{plan_rel} Plan section is too long ({len(plan_items)} steps > 8); keep to small verifiable steps"
|
|
271
|
+
)
|
|
272
|
+
concrete_count = sum(1 for item in plan_items if is_concrete_plan_item(item))
|
|
273
|
+
min_concrete = 1 if len(plan_items) <= 2 else ((len(plan_items) * 3 + 4) // 5)
|
|
274
|
+
if concrete_count < min_concrete:
|
|
275
|
+
(errors if strict else warnings).append(
|
|
276
|
+
f"{plan_rel} Plan steps are too abstract ({concrete_count}/{len(plan_items)} concrete, need >= {min_concrete}); include files/commands per step"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
verify_body = section_content.get("Verify", "")
|
|
280
|
+
if verify_body.strip():
|
|
281
|
+
if not has_runnable_command(verify_body):
|
|
282
|
+
(errors if strict else warnings).append(
|
|
283
|
+
f"{plan_rel} Verify section must contain runnable command(s)"
|
|
284
|
+
)
|
|
285
|
+
if not re.search(r"(?i)(期望|预期|expected)", verify_body):
|
|
286
|
+
(errors if strict else warnings).append(
|
|
287
|
+
f"{plan_rel} Verify section must include expected result (期望/预期/expected)"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return errors, warnings
|
|
291
|
+
|
|
292
|
+
|
|
89
293
|
def csv_has_id(path: Path, column: str, value: str) -> bool:
|
|
90
294
|
with path.open("r", encoding="utf-8", errors="replace", newline="") as f:
|
|
91
295
|
reader = csv.DictReader(f)
|
|
@@ -187,6 +391,12 @@ def validate_change(
|
|
|
187
391
|
|
|
188
392
|
req_id = ""
|
|
189
393
|
prob_id = ""
|
|
394
|
+
change_id_decl = ""
|
|
395
|
+
contract_row_decl = ""
|
|
396
|
+
plan_file_decl = ""
|
|
397
|
+
evidence_path_decl = ""
|
|
398
|
+
req_csv = root / "requirements" / "requirements-issues.csv"
|
|
399
|
+
prob_csv = root / "issues" / "problem-issues.csv"
|
|
190
400
|
|
|
191
401
|
if proposal_path:
|
|
192
402
|
t = read_text(proposal_path)
|
|
@@ -196,13 +406,25 @@ def validate_change(
|
|
|
196
406
|
if "AI_WORKSPACE.md" not in t:
|
|
197
407
|
warnings.append("proposal.md does not reference AI_WORKSPACE.md (recommended)")
|
|
198
408
|
|
|
409
|
+
change_id_decl = extract_id("Change_ID", t)
|
|
199
410
|
req_id = extract_id("Req_ID", t)
|
|
200
411
|
prob_id = extract_id("Problem_ID", t)
|
|
412
|
+
contract_row_decl = extract_first_id(["Contract_Row", "Contract_Row(s)"], t)
|
|
413
|
+
plan_file_decl = extract_first_id(["Plan_File", "Plan file"], t)
|
|
414
|
+
evidence_path_decl = extract_first_id(["Evidence_Path", "Evidence_Path(s)"], t)
|
|
415
|
+
|
|
416
|
+
if strict and not change_id_decl:
|
|
417
|
+
errors.append("proposal.md must include non-empty Change_ID")
|
|
418
|
+
if change_id_decl and change_id_decl != change_id:
|
|
419
|
+
(errors if strict else warnings).append(f"Change_ID mismatch in proposal.md: {change_id_decl} (expected: {change_id})")
|
|
201
420
|
if strict and not (req_id or prob_id):
|
|
202
421
|
errors.append("proposal.md must include a non-empty Req_ID or Problem_ID (attribution)")
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
422
|
+
if strict and not contract_row_decl:
|
|
423
|
+
errors.append("proposal.md must include non-empty Contract_Row")
|
|
424
|
+
if strict and not plan_file_decl:
|
|
425
|
+
errors.append("proposal.md must include non-empty Plan_File")
|
|
426
|
+
if strict and not evidence_path_decl:
|
|
427
|
+
errors.append("proposal.md must include non-empty Evidence_Path")
|
|
206
428
|
|
|
207
429
|
if req_id and req_csv.exists():
|
|
208
430
|
ok = False
|
|
@@ -222,6 +444,127 @@ def validate_change(
|
|
|
222
444
|
if not ok:
|
|
223
445
|
(errors if strict else warnings).append(f"Problem_ID not found in issues/problem-issues.csv: {prob_id}")
|
|
224
446
|
|
|
447
|
+
normalized_contract_refs: List[Tuple[str, str]] = []
|
|
448
|
+
for raw_ref in split_declared_values(contract_row_decl):
|
|
449
|
+
kind, value = normalize_contract_ref(raw_ref)
|
|
450
|
+
found = False
|
|
451
|
+
resolved_kind = kind
|
|
452
|
+
try:
|
|
453
|
+
if kind == "Req_ID":
|
|
454
|
+
found = req_csv.exists() and csv_has_id(req_csv, "Req_ID", value)
|
|
455
|
+
elif kind == "Problem_ID":
|
|
456
|
+
found = prob_csv.exists() and csv_has_id(prob_csv, "Problem_ID", value)
|
|
457
|
+
else:
|
|
458
|
+
if req_csv.exists() and csv_has_id(req_csv, "Req_ID", value):
|
|
459
|
+
found = True
|
|
460
|
+
resolved_kind = "Req_ID"
|
|
461
|
+
elif prob_csv.exists() and csv_has_id(prob_csv, "Problem_ID", value):
|
|
462
|
+
found = True
|
|
463
|
+
resolved_kind = "Problem_ID"
|
|
464
|
+
except Exception as e:
|
|
465
|
+
warnings.append(f"failed to resolve Contract_Row reference {raw_ref}: {e}")
|
|
466
|
+
if not found:
|
|
467
|
+
(errors if strict else warnings).append(f"Contract_Row not found in requirements/problem CSVs: {raw_ref}")
|
|
468
|
+
continue
|
|
469
|
+
normalized_contract_refs.append((resolved_kind, value))
|
|
470
|
+
|
|
471
|
+
if req_id:
|
|
472
|
+
has_req_binding = any(kind == "Req_ID" and value == req_id for kind, value in normalized_contract_refs)
|
|
473
|
+
if not has_req_binding:
|
|
474
|
+
(errors if strict else warnings).append(
|
|
475
|
+
f"Contract_Row must include Req_ID binding for proposal Req_ID: {req_id}"
|
|
476
|
+
)
|
|
477
|
+
if prob_id:
|
|
478
|
+
has_prob_binding = any(kind == "Problem_ID" and value == prob_id for kind, value in normalized_contract_refs)
|
|
479
|
+
if not has_prob_binding:
|
|
480
|
+
(errors if strict else warnings).append(
|
|
481
|
+
f"Contract_Row must include Problem_ID binding for proposal Problem_ID: {prob_id}"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
plan_files = split_declared_values(plan_file_decl)
|
|
485
|
+
if strict and len(plan_files) > 1:
|
|
486
|
+
errors.append("proposal.md Plan_File must contain exactly one plan path")
|
|
487
|
+
if plan_files:
|
|
488
|
+
plan_rel = plan_files[0]
|
|
489
|
+
if plan_rel.startswith("./"):
|
|
490
|
+
plan_rel = plan_rel[2:]
|
|
491
|
+
if os.path.isabs(plan_rel):
|
|
492
|
+
(errors if strict else warnings).append(f"Plan_File must be workspace-relative, got absolute path: {plan_rel}")
|
|
493
|
+
if not plan_rel.startswith("plan/"):
|
|
494
|
+
(errors if strict else warnings).append(f"Plan_File should be under plan/: {plan_rel}")
|
|
495
|
+
|
|
496
|
+
plan_abs = (root / plan_rel).resolve()
|
|
497
|
+
try:
|
|
498
|
+
plan_abs.relative_to(root)
|
|
499
|
+
except Exception:
|
|
500
|
+
(errors if strict else warnings).append(f"Plan_File points outside workspace: {plan_rel}")
|
|
501
|
+
else:
|
|
502
|
+
if not plan_abs.exists():
|
|
503
|
+
(errors if strict else warnings).append(f"Plan_File does not exist: {plan_rel}")
|
|
504
|
+
else:
|
|
505
|
+
plan_text = read_text(plan_abs)
|
|
506
|
+
plan_change_id = extract_id("Change_ID", plan_text)
|
|
507
|
+
plan_req_id = extract_id("Req_ID", plan_text)
|
|
508
|
+
plan_prob_id = extract_id("Problem_ID", plan_text)
|
|
509
|
+
plan_contract_row = extract_first_id(["Contract_Row", "Contract_Row(s)"], plan_text)
|
|
510
|
+
plan_file_from_plan = extract_first_id(["Plan_File", "Plan file"], plan_text)
|
|
511
|
+
plan_evidence_path = extract_first_id(["Evidence_Path", "Evidence_Path(s)"], plan_text)
|
|
512
|
+
|
|
513
|
+
if strict and not plan_change_id:
|
|
514
|
+
errors.append(f"{plan_rel} must include non-empty Change_ID")
|
|
515
|
+
if plan_change_id and plan_change_id != change_id:
|
|
516
|
+
(errors if strict else warnings).append(
|
|
517
|
+
f"Change_ID mismatch in {plan_rel}: {plan_change_id} (expected: {change_id})"
|
|
518
|
+
)
|
|
519
|
+
if strict and not (plan_req_id or plan_prob_id):
|
|
520
|
+
errors.append(f"{plan_rel} must include non-empty Req_ID or Problem_ID")
|
|
521
|
+
if req_id and plan_req_id != req_id:
|
|
522
|
+
(errors if strict else warnings).append(
|
|
523
|
+
f"Req_ID mismatch between proposal.md and {plan_rel}: proposal={req_id}, plan={plan_req_id or '(empty)'}"
|
|
524
|
+
)
|
|
525
|
+
if prob_id and plan_prob_id != prob_id:
|
|
526
|
+
(errors if strict else warnings).append(
|
|
527
|
+
f"Problem_ID mismatch between proposal.md and {plan_rel}: proposal={prob_id}, plan={plan_prob_id or '(empty)'}"
|
|
528
|
+
)
|
|
529
|
+
if strict and not plan_contract_row:
|
|
530
|
+
errors.append(f"{plan_rel} must include non-empty Contract_Row")
|
|
531
|
+
if strict and not plan_file_from_plan:
|
|
532
|
+
errors.append(f"{plan_rel} must include non-empty Plan_File")
|
|
533
|
+
if strict and not plan_evidence_path:
|
|
534
|
+
errors.append(f"{plan_rel} must include non-empty Evidence_Path")
|
|
535
|
+
|
|
536
|
+
if plan_file_from_plan:
|
|
537
|
+
plan_file_refs = split_declared_values(plan_file_from_plan)
|
|
538
|
+
normalized_plan_ref = plan_file_refs[0] if plan_file_refs else ""
|
|
539
|
+
if normalized_plan_ref.startswith("./"):
|
|
540
|
+
normalized_plan_ref = normalized_plan_ref[2:]
|
|
541
|
+
if normalized_plan_ref and normalized_plan_ref != plan_rel:
|
|
542
|
+
(errors if strict else warnings).append(
|
|
543
|
+
f"Plan_File mismatch in {plan_rel}: {normalized_plan_ref} (expected: {plan_rel})"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
proposal_contract_set = {normalize_contract_ref(v) for v in split_declared_values(contract_row_decl)}
|
|
547
|
+
plan_contract_set = {normalize_contract_ref(v) for v in split_declared_values(plan_contract_row)}
|
|
548
|
+
missing_contract = proposal_contract_set - plan_contract_set
|
|
549
|
+
if missing_contract:
|
|
550
|
+
missing_text = ", ".join([f"{kind}={value}" for kind, value in sorted(missing_contract)])
|
|
551
|
+
(errors if strict else warnings).append(
|
|
552
|
+
f"{plan_rel} missing Contract_Row bindings declared in proposal.md: {missing_text}"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
proposal_evidence_set = set(split_declared_values(evidence_path_decl))
|
|
556
|
+
plan_evidence_set = set(split_declared_values(plan_evidence_path))
|
|
557
|
+
missing_evidence = proposal_evidence_set - plan_evidence_set
|
|
558
|
+
if missing_evidence:
|
|
559
|
+
missing_text = ", ".join(sorted(missing_evidence))
|
|
560
|
+
(errors if strict else warnings).append(
|
|
561
|
+
f"{plan_rel} missing Evidence_Path entries declared in proposal.md: {missing_text}"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
quality_errors, quality_warnings = validate_plan_quality(plan_text, plan_rel, strict)
|
|
565
|
+
errors.extend(quality_errors)
|
|
566
|
+
warnings.extend(quality_warnings)
|
|
567
|
+
|
|
225
568
|
if tasks_path:
|
|
226
569
|
t = read_text(tasks_path)
|
|
227
570
|
placeholder_scan("tasks.md", t)
|
|
@@ -249,7 +592,11 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
249
592
|
default="",
|
|
250
593
|
help="Branch name to validate against (for CI detached HEAD). When set, enforces change/<id> naming.",
|
|
251
594
|
)
|
|
252
|
-
parser.add_argument(
|
|
595
|
+
parser.add_argument(
|
|
596
|
+
"--strict",
|
|
597
|
+
action="store_true",
|
|
598
|
+
help="Treat WS:TODO as errors; require main bindings and enforce plan quality gate (sections, step granularity, verify commands).",
|
|
599
|
+
)
|
|
253
600
|
parser.add_argument(
|
|
254
601
|
"--allow-truth-drift",
|
|
255
602
|
action="store_true",
|