@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aipper/aiws-spec",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "AIWS spec and templates (single source of truth).",
5
5
  "type": "module",
6
6
  "files": [
@@ -19,5 +19,5 @@ fi
19
19
  ```
20
20
 
21
21
  说明:
22
- - `--strict` 会把 `WS:TODO`/缺少归因等视为错误(预期在工件未完善前会失败)
22
+ - `--strict` 会把 `WS:TODO`/缺少归因视为错误,并启用计划质量门(章节完整、步骤粒度、验证命令与预期)
23
23
  - 紧急情况下可用 `--allow-truth-drift`(不推荐)
@@ -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
- 5) 计划内容至少包含(不要留空):
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
- 6) 若计划涉及“需求/验收”变更:先用 `$ws-req-review` 评审 用户确认后再 `$ws-req-change` 落盘(避免需求漂移)。
35
- 7) 多步任务(≥2 步):后续进入实现时,使用 `update_plan` 工具跟踪 `pending → in_progress → completed`。
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:` 推荐下一步(通常是 `$ws-dev` `aiws change start <change-id> --hooks`;superproject + submodule 可用 `--worktree`)
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
- - 需求交付:`Req_ID` = <!-- WS:TODO -->
18
- - 问题修复:`Problem_ID` = <!-- WS:TODO -->
19
-
20
- > 备注:若“问题阻塞需求”,两边都要在各自 CSV `Notes` 字段互相引用对方 ID
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)}.*?[:=]\s*(.+)$", text)
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
- req_csv = root / "requirements" / "requirements-issues.csv"
205
- prob_csv = root / "issues" / "problem-issues.csv"
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("--strict", action="store_true", help="Treat WS:TODO as errors and require attribution IDs.")
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",