agent-project-sdlc 0.1.19 → 0.1.21

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.
Files changed (39) hide show
  1. package/README.md +9 -7
  2. package/assets/agents/AGENTS_CORE.md +2 -2
  3. package/assets/docs/README.md +10 -8
  4. package/assets/skills/pjsdlc_architect_design/SKILL.md +2 -0
  5. package/assets/skills/pjsdlc_dev_sprint/SKILL.md +7 -5
  6. package/assets/skills/pjsdlc_implementation_doc/SKILL.md +2 -2
  7. package/assets/skills/pjsdlc_manager/SKILL.md +4 -4
  8. package/assets/skills/pjsdlc_pm_prd/SKILL.md +3 -3
  9. package/assets/skills/pjsdlc_release_manager/SKILL.md +2 -0
  10. package/assets/skills/pjsdlc_reviewer/SKILL.md +2 -0
  11. package/assets/skills/pjsdlc_rfc_recalibrate/SKILL.md +2 -0
  12. package/assets/skills/pjsdlc_tester/SKILL.md +2 -2
  13. package/assets/templates/IMPLEMENTATION_DOC_TEMPLATE.md +1 -1
  14. package/assets/templates/PLAN_TEMPLATE.yaml +9 -6
  15. package/assets/tools/build_doc_overviews.py +152 -0
  16. package/assets/tools/harness_utils.py +858 -0
  17. package/assets/tools/impact_analyzer.py +51 -0
  18. package/assets/tools/run_current_gate.py +29 -0
  19. package/assets/tools/status.py +29 -0
  20. package/assets/tools/transition.py +68 -0
  21. package/assets/tools/validate_allowed_paths.py +44 -0
  22. package/assets/tools/validate_design.py +199 -0
  23. package/assets/tools/validate_dev_state.py +20 -0
  24. package/assets/tools/validate_harness.py +60 -0
  25. package/assets/tools/validate_plan.py +24 -0
  26. package/assets/tools/validate_plan_draft.py +19 -0
  27. package/assets/tools/validate_prd.py +27 -0
  28. package/assets/tools/validate_prompt_language.py +138 -0
  29. package/assets/tools/validate_release_plan.py +37 -0
  30. package/assets/tools/validate_review.py +59 -0
  31. package/assets/tools/validate_rfc.py +105 -0
  32. package/assets/tools/validate_task_docs.py +40 -0
  33. package/assets/tools/validate_test_plan.py +82 -0
  34. package/dist/lib/config.js +1 -0
  35. package/dist/lib/migrations.js +3 -0
  36. package/dist/lib/sync-engine.js +4 -0
  37. package/dist/lib/validators.js +227 -17
  38. package/package.json +1 -1
  39. package/source-mappings.yaml +6 -0
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from harness_utils import ROOT, make_arg_parser, repo_path, require, run_main
7
+
8
+
9
+ def tokens(text: str) -> set[str]:
10
+ raw = re.findall(r"[A-Za-z0-9_./-]{3,}|[\u4e00-\u9fff]{2,}", text)
11
+ stop = {"the", "and", "for", "with", "status", "draft", "added", "changed", "removed"}
12
+ return {item.lower() for item in raw if item.lower() not in stop}
13
+
14
+
15
+ def score_file(path: Path, query_tokens: set[str]) -> int:
16
+ try:
17
+ content = path.read_text(encoding="utf-8", errors="ignore").lower()
18
+ except Exception:
19
+ return 0
20
+ return sum(1 for token in query_tokens if token in content)
21
+
22
+
23
+ def main() -> None:
24
+ parser = make_arg_parser("Generate candidate impact scope for an RFC")
25
+ parser.add_argument("--rfc", required=True, help="RFC path relative to repository root")
26
+ parser.add_argument("--top", type=int, default=30, help="Maximum candidates to print")
27
+ args = parser.parse_args()
28
+
29
+ rfc_path = repo_path(args.rfc)
30
+ require(rfc_path.exists(), f"RFC file not found: {args.rfc}")
31
+ query_tokens = tokens(rfc_path.read_text(encoding="utf-8"))
32
+ require(query_tokens, "RFC does not contain enough analyzable terms")
33
+
34
+ candidates = []
35
+ roots = [".docs", "src", "tests"]
36
+ for root in roots:
37
+ base = repo_path(root)
38
+ if not base.exists():
39
+ continue
40
+ for path in base.rglob("*"):
41
+ if path.is_file() and path.name != ".gitkeep":
42
+ score = score_file(path, query_tokens)
43
+ if score:
44
+ candidates.append({"path": str(path.relative_to(ROOT)), "score": score})
45
+
46
+ candidates.sort(key=lambda item: (-item["score"], item["path"]))
47
+ print(json.dumps({"rfc": args.rfc, "candidates": candidates[: args.top]}, ensure_ascii=False, indent=2))
48
+
49
+
50
+ if __name__ == "__main__":
51
+ run_main(main)
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env python3
2
+ import subprocess
3
+
4
+ from harness_utils import load_lifecycle, load_phase_contracts, load_plan, require, run_main, ROOT, validate_plan_contract
5
+
6
+
7
+ def main() -> None:
8
+ lifecycle = load_lifecycle()
9
+ phases = load_phase_contracts()
10
+ phase_name = lifecycle.get("current_phase")
11
+ require(phase_name in phases, f"Current phase not found in phase contracts: {phase_name}")
12
+ gates = phases[phase_name].get("gates") or []
13
+ if not gates:
14
+ print(f"No gate configured for phase {phase_name}")
15
+ return
16
+
17
+ for gate in gates:
18
+ print(f"Running {gate}")
19
+ proc = subprocess.run(gate, cwd=ROOT, shell=True)
20
+ result = "PASS" if proc.returncode == 0 else "FAIL"
21
+ print(f"{gate}: {result}")
22
+ require(proc.returncode == 0, f"Gate failed for {phase_name}: {gate}")
23
+
24
+ validate_plan_contract(load_plan(), allow_open=False)
25
+ print("Phase exit plan OK: no open tasks")
26
+
27
+
28
+ if __name__ == "__main__":
29
+ run_main(main)
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import load_lifecycle, load_plan, run_main
3
+
4
+
5
+ def main() -> None:
6
+ lifecycle = load_lifecycle()
7
+ tasks_data = load_plan()
8
+ tasks = tasks_data.get("tasks", [])
9
+ current_task_id = tasks_data.get("current_task_id") or ""
10
+ current_task = next((task for task in tasks if task.get("id") == current_task_id), None)
11
+ active_count = len(tasks)
12
+
13
+ print(f"Current phase: {lifecycle.get('current_phase')}")
14
+ print(f"Active role: {lifecycle.get('active_role')}")
15
+ print(f"Active skill: {lifecycle.get('active_skill')}")
16
+ print(f"Milestone: {lifecycle.get('current_milestone')}")
17
+ print(f"Allowed next phases: {', '.join(lifecycle.get('allowed_next_phases') or []) or 'none'}")
18
+ print(f"Plan tasks: {active_count} active/future")
19
+ print(f"Next task sequence: {tasks_data.get('next_task_sequence') or 'unset'}")
20
+ if current_task:
21
+ print(f"Current task: {current_task.get('id')} {current_task.get('title')} [{current_task.get('status')}]")
22
+ else:
23
+ print("Current task: none")
24
+ if lifecycle.get("blocked_reason"):
25
+ print(f"Blocked reason: {lifecycle.get('blocked_reason')}")
26
+
27
+
28
+ if __name__ == "__main__":
29
+ run_main(main)
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import dump_yaml, load_lifecycle, load_phase_contracts, make_arg_parser, require, run_main
3
+
4
+
5
+ RFC_INTERRUPT_SOURCES = {"SPRINTING", "REVIEWING", "TESTING", "RELEASING"}
6
+
7
+
8
+ def phase_targets(phase: dict) -> list[str]:
9
+ targets: list[str] = []
10
+ next_phase = phase.get("next")
11
+ if next_phase:
12
+ targets.append(str(next_phase))
13
+ for return_phase in phase.get("returns") or []:
14
+ if return_phase:
15
+ targets.append(str(return_phase))
16
+ return list(dict.fromkeys(targets))
17
+
18
+
19
+ def main() -> None:
20
+ parser = make_arg_parser("Transition AI SDLC Harness lifecycle phase")
21
+ parser.add_argument("--to", required=True, help="Target lifecycle phase")
22
+ parser.add_argument("--reason", default="", help="Short compatibility note; not persisted in active state")
23
+ parser.add_argument("--force", action="store_true", help="Allow transition outside configured next phases")
24
+ args = parser.parse_args()
25
+
26
+ lifecycle = load_lifecycle()
27
+ phases = load_phase_contracts()
28
+ target = args.to
29
+ current = lifecycle.get("current_phase")
30
+ require(target in phases, f"Unknown target phase: {target}")
31
+ require(current in phases, f"Current phase is not declared in phase_contracts.yaml: {current}")
32
+
33
+ legal = set(lifecycle.get("allowed_next_phases") or [])
34
+ legal.update(phase_targets(phases[current]))
35
+ if target == "RFC_RECALIBRATION" and current in RFC_INTERRUPT_SOURCES:
36
+ legal.add(target)
37
+ if target == "BLOCKED":
38
+ legal.add(target)
39
+ suspended = lifecycle.get("suspended_phase")
40
+ if current == "BLOCKED" and suspended:
41
+ legal.add(suspended)
42
+
43
+ require(args.force or target in legal, f"Illegal transition {current} -> {target}. Legal: {sorted(legal)}")
44
+
45
+ if target in {"RFC_RECALIBRATION", "BLOCKED"} and current not in {"RFC_RECALIBRATION", "BLOCKED"}:
46
+ lifecycle["suspended_phase"] = current
47
+ elif current == "RFC_RECALIBRATION" and target == "SPRINTING":
48
+ lifecycle["suspended_phase"] = ""
49
+ elif suspended and target == suspended:
50
+ lifecycle["suspended_phase"] = ""
51
+
52
+ phase = phases[target]
53
+ lifecycle["current_phase"] = target
54
+ lifecycle["active_role"] = phase.get("role", "")
55
+ lifecycle["active_skill"] = phase.get("skill", "")
56
+
57
+ lifecycle["allowed_next_phases"] = phase_targets(phase)
58
+ if target == "BLOCKED" and lifecycle.get("suspended_phase"):
59
+ lifecycle["allowed_next_phases"] = [lifecycle["suspended_phase"]]
60
+
61
+ dump_yaml(lifecycle, ".codex/state/lifecycle.yaml")
62
+ print(f"Transitioned {current} -> {target}")
63
+ if args.reason:
64
+ print(f"Note: {args.reason}")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ run_main(main)
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import (
3
+ OPEN_TASK_STATUSES,
4
+ changed_files,
5
+ expand_harness_root,
6
+ load_lifecycle,
7
+ load_plan,
8
+ load_yaml,
9
+ matches_any,
10
+ require,
11
+ run_main,
12
+ task_by_id,
13
+ )
14
+
15
+
16
+ def main() -> None:
17
+ data = load_plan()
18
+ tasks = [task for task in data.get("tasks", []) if isinstance(task, dict)]
19
+ open_tasks = [task for task in tasks if task.get("status") in OPEN_TASK_STATUSES]
20
+
21
+ policies = load_yaml(".codex/pjsdlc_managed/policies/allowed_paths.yaml")
22
+ lifecycle = load_lifecycle()
23
+ current_phase = lifecycle.get("current_phase") or "SPRINTING"
24
+ phase_policy = ((policies.get("phases") or {}).get(current_phase) or {})
25
+ always_allow = expand_harness_root(phase_policy.get("always_allow") or [])
26
+
27
+ if open_tasks:
28
+ current_task_id = data.get("current_task_id") or ""
29
+ task = task_by_id(data, current_task_id) if current_task_id else None
30
+ require(task, "current_task_id must point to the task being validated")
31
+ require(task.get("status") in OPEN_TASK_STATUSES, "current_task_id must point to an open task for path validation")
32
+ allowed = list(task.get("allowed_paths") or []) + list(always_allow)
33
+ else:
34
+ print("Allowed paths skipped: no open task")
35
+ return
36
+
37
+ changed = [path for path in changed_files() if not path.startswith(".git/")]
38
+ blocked = [path for path in changed if not matches_any(path, allowed)]
39
+ require(not blocked, "Changed files outside current task allowed_paths: " + ", ".join(blocked))
40
+ print(f"Allowed paths OK: {len(changed)} changed file(s) checked")
41
+
42
+
43
+ if __name__ == "__main__":
44
+ run_main(main)
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ import re
3
+ from harness_utils import (
4
+ combined_text,
5
+ contains_any,
6
+ load_lifecycle,
7
+ load_plan,
8
+ markdown_deliverables,
9
+ repo_path,
10
+ run_main,
11
+ require,
12
+ validate_plan_contract,
13
+ validate_task_shape,
14
+ )
15
+
16
+ CROSS_CUTTING_CATEGORIES = [
17
+ {
18
+ "name": "ai",
19
+ "label": "AI copilot/provider",
20
+ "trigger_terms": ["ai provider", "ai output", "aioutput", "llm", "copilot", "副驾驶"],
21
+ "architecture_terms": ["ai provider", "ai output", "llm", "copilot", "副驾驶", "模型", "智能", "prompt"],
22
+ },
23
+ {
24
+ "name": "external",
25
+ "label": "external system boundary",
26
+ "trigger_terms": ["external system", "external integration", "webhook", "外部系统", "第三方", "微信", "工商", "税务", "社保", "公积金", "金蝶", "对象存储"],
27
+ "architecture_terms": ["external system", "external integration", "webhook", "adapter", "适配", "边界", "外部系统", "第三方", "微信", "工商", "税务", "社保", "公积金", "金蝶", "对象存储"],
28
+ },
29
+ {
30
+ "name": "compliance",
31
+ "label": "compliance/permission/audit",
32
+ "trigger_terms": ["compliance", "authorization", "audit log", "audit trail", "合规", "授权", "客户确认", "回执归档", "权限模型", "权限控制", "权限架构", "审计架构", "审计日志"],
33
+ "architecture_terms": ["compliance", "permission", "authorization", "audit", "合规", "权限", "审计", "授权", "客户确认", "回执归档"],
34
+ },
35
+ ]
36
+
37
+
38
+ def main() -> None:
39
+ lifecycle = load_lifecycle()
40
+ plan = load_plan()
41
+ validate_plan_contract(plan, allow_open=lifecycle.get("current_phase") != "ARCHITECTING")
42
+
43
+ architecture_docs = markdown_deliverables(".docs/02_architecture")
44
+ tech_plan_docs = markdown_deliverables(".docs/03_tech_plan")
45
+ product_docs = markdown_deliverables(".docs/01_product")
46
+ require(architecture_docs, "No architecture deliverables found in .docs/02_architecture/")
47
+ require(tech_plan_docs, "No technical plan deliverables found in .docs/03_tech_plan/")
48
+
49
+ text = combined_text(architecture_docs + tech_plan_docs)
50
+ require(contains_any(text, ["prd", "requirement", "需求"]), "Design must cite product requirements")
51
+ require(contains_any(text, ["api", "interface", "接口", "contract", "契约"]), "Design must describe interfaces or contracts")
52
+ require(contains_any(text, ["task", "任务", "breakdown"]), "Design must include task breakdown")
53
+ draft_tasks = validate_draft_task_tech_plan_refs(tech_plan_docs)
54
+ validate_cross_cutting_architecture(product_docs, tech_plan_docs, architecture_docs, draft_tasks)
55
+ print(f"Design artifacts OK: {len(architecture_docs)} architecture, {len(tech_plan_docs)} tech plan")
56
+
57
+
58
+ def validate_draft_task_tech_plan_refs(tech_plan_docs: list) -> list[dict]:
59
+ draft = load_plan(".codex/state/plan.draft.yaml")
60
+ require("current_phase" not in draft, "plan.draft.yaml must not define current_phase; lifecycle.yaml is the single source for current_phase")
61
+ require("current_task_id" not in draft, "plan.draft.yaml must not define current_task_id because drafts are not active task state")
62
+ tasks = draft.get("tasks", [])
63
+ require(tasks, "plan.draft.yaml must contain at least one task before leaving ARCHITECTING")
64
+
65
+ available_tech_plans = {repo_relative(path) for path in tech_plan_docs}
66
+ development_tasks: list[dict] = []
67
+ primary_refs: list[str] = []
68
+ for index, task in enumerate(tasks):
69
+ require(isinstance(task, dict), f"Task draft #{index + 1} must be a mapping")
70
+ validate_task_shape(task, index)
71
+ require(task.get("status") == "pending", f"Draft task {task.get('id')} should start as pending")
72
+ if not is_development_draft(task):
73
+ continue
74
+ development_tasks.append(task)
75
+ docs = task.get("docs")
76
+ require(isinstance(docs, dict), f"Draft task {task.get('id')} docs must be a mapping")
77
+ tech_refs = as_list(docs.get("tech_plan"))
78
+ require(tech_refs, f"Draft task {task.get('id')} must reference at least one tech plan slice in docs.tech_plan")
79
+ normalized_refs = [normalize_doc_ref(ref) for ref in tech_refs]
80
+ for ref in normalized_refs:
81
+ require(ref.startswith(".docs/03_tech_plan/"), f"Draft task {task.get('id')} docs.tech_plan must point into .docs/03_tech_plan/: {ref}")
82
+ require(ref in available_tech_plans, f"Draft task {task.get('id')} references missing or generated tech plan slice: {ref}")
83
+ validate_self_test_contract_tech_plan_binding(task, normalized_refs)
84
+ primary_refs.append(normalized_refs[0])
85
+
86
+ require(development_tasks, "plan.draft.yaml must contain at least one development task with implementation_doc")
87
+ if len(development_tasks) > 1:
88
+ require(
89
+ len(set(primary_refs)) == len(primary_refs),
90
+ "Draft development tasks must reference distinct primary tech plan slices in docs.tech_plan",
91
+ )
92
+ return tasks
93
+
94
+
95
+ def validate_cross_cutting_architecture(product_docs: list, tech_plan_docs: list, architecture_docs: list, draft_tasks: list[dict]) -> None:
96
+ source_text = "\n".join(
97
+ [
98
+ combined_text(product_docs),
99
+ combined_text(tech_plan_docs),
100
+ "\n".join(task_text(task) for task in draft_tasks),
101
+ ]
102
+ )
103
+ triggered = [category for category in CROSS_CUTTING_CATEGORIES if contains_any(source_text, category["trigger_terms"])]
104
+ assigned_docs: set[str] = set()
105
+ for category in triggered:
106
+ matches = [
107
+ doc
108
+ for doc in architecture_docs
109
+ if repo_relative(doc) not in assigned_docs and contains_any(doc.read_text(encoding="utf-8"), category["architecture_terms"])
110
+ ]
111
+ require(matches, f"Design requires a dedicated {category['label']} architecture slice")
112
+ assigned_docs.add(repo_relative(matches[0]))
113
+
114
+
115
+ def validate_self_test_contract_tech_plan_binding(task: dict, normalized_tech_refs: list[str]) -> None:
116
+ contract = task.get("self_test_contract")
117
+ if not isinstance(contract, dict) or contract.get("status") != "required":
118
+ return
119
+ source = normalize_doc_ref(str(contract.get("source") or ""))
120
+ task_id = task.get("id")
121
+ require(source in normalized_tech_refs, f"Draft task {task_id} self_test_contract.source must be listed in docs.tech_plan: {source}")
122
+ source_path = repo_path(source)
123
+ if not source_path.exists():
124
+ return
125
+ text = source_path.read_text(encoding="utf-8")
126
+ section = markdown_section(text, ["development self-test contract", "开发自测合同"])
127
+ require(section, f"Draft task {task_id} self_test_contract.source must contain a Development Self-Test Contract section: {source}")
128
+ require(
129
+ contains_any(section, ["module key test path", "模块关键测试路径"]),
130
+ f"Draft task {task_id} tech plan Development Self-Test Contract must include Module key test path: {source}",
131
+ )
132
+ for scenario in contract.get("scenarios") or []:
133
+ if not isinstance(scenario, dict):
134
+ continue
135
+ scenario_id = str(scenario.get("id") or "").strip()
136
+ if scenario_id:
137
+ require(scenario_id in section, f"Draft task {task_id} tech plan Development Self-Test Contract must include scenario {scenario_id}: {source}")
138
+
139
+
140
+ def markdown_section(text: str, header_terms: list[str]) -> str:
141
+ lines = text.splitlines()
142
+ start = -1
143
+ level = 0
144
+ for index, line in enumerate(lines):
145
+ match = re.match(r"^(#{1,6})\s+(.+)$", line)
146
+ if not match:
147
+ continue
148
+ title = match.group(2).lower()
149
+ if any(term.lower() in title for term in header_terms):
150
+ start = index
151
+ level = len(match.group(1))
152
+ break
153
+ if start == -1:
154
+ return ""
155
+ end = len(lines)
156
+ for index in range(start + 1, len(lines)):
157
+ match = re.match(r"^(#{1,6})\s+", lines[index])
158
+ if match and len(match.group(1)) <= level:
159
+ end = index
160
+ break
161
+ return "\n".join(lines[start:end])
162
+
163
+
164
+ def is_development_draft(task: dict) -> bool:
165
+ task_id = str(task.get("id") or "")
166
+ return bool(task.get("implementation_doc")) or task.get("phase") == "SPRINTING" or task_id.startswith("DEV-")
167
+
168
+
169
+ def as_list(value) -> list[str]:
170
+ if isinstance(value, list):
171
+ return [str(item).strip() for item in value if str(item).strip()]
172
+ if isinstance(value, str) and value.strip():
173
+ return [value.strip()]
174
+ return []
175
+
176
+
177
+ def normalize_doc_ref(value: str) -> str:
178
+ ref = value.replace("\\", "/")
179
+ return ref[2:] if ref.startswith("./") else ref
180
+
181
+
182
+ def repo_relative(path) -> str:
183
+ return path.resolve().relative_to(repo_path(".").resolve()).as_posix()
184
+
185
+
186
+ def task_text(task: dict) -> str:
187
+ parts = []
188
+ for key in ["id", "title", "summary", "phase"]:
189
+ if task.get(key):
190
+ parts.append(str(task[key]))
191
+ docs = task.get("docs")
192
+ if isinstance(docs, dict):
193
+ for value in docs.values():
194
+ parts.extend(as_list(value))
195
+ return "\n".join(parts)
196
+
197
+
198
+ if __name__ == "__main__":
199
+ run_main(main)
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import load_plan, require, run_main
3
+
4
+
5
+ def main() -> None:
6
+ draft = load_plan(".codex/state/plan.draft.yaml")
7
+ require("current_phase" not in draft, "plan.draft.yaml must not define current_phase; lifecycle.yaml is the single source for current_phase")
8
+ require("current_task_id" not in draft, "plan.draft.yaml must not define current_task_id because drafts are not active task state")
9
+ tasks = [task for task in draft.get("tasks", []) if isinstance(task, dict)]
10
+ require(
11
+ not tasks,
12
+ "Unconsumed draft tasks remain in plan.draft.yaml: "
13
+ + ", ".join(str(task.get("id") or "<missing id>") for task in tasks)
14
+ + ". Promote the next draft into plan.yaml or remove already-consumed drafts before validate-dev.",
15
+ )
16
+ print("Dev state OK: 0 unconsumed draft task(s)")
17
+
18
+
19
+ if __name__ == "__main__":
20
+ run_main(main)
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import load_lifecycle, load_phase_contracts, load_yaml, repo_path, require, require_paths, run_main
3
+
4
+
5
+ def main() -> None:
6
+ required_files = [
7
+ "AGENTS.md",
8
+ "Makefile",
9
+ ".docs/INDEX.md",
10
+ ".codex/state/lifecycle.yaml",
11
+ ".codex/state/plan.yaml",
12
+ ".codex/state/plan.draft.yaml",
13
+ ".codex/state/memory.md",
14
+ ".codex/pjsdlc_managed/templates/PLAN_TEMPLATE.yaml",
15
+ ".codex/pjsdlc_managed/policies/phase_contracts.yaml",
16
+ ".codex/pjsdlc_managed/policies/gates.yaml",
17
+ ".codex/pjsdlc_managed/policies/allowed_paths.yaml",
18
+ ".codex/pjsdlc_managed/policies/risk_matrix.yaml",
19
+ "tools/build_doc_overviews.py",
20
+ "tools/validate_plan.py",
21
+ ]
22
+ required_dirs = [
23
+ ".docs/00_raw",
24
+ ".docs/01_product",
25
+ ".docs/02_architecture",
26
+ ".docs/03_tech_plan",
27
+ ".docs/04_implementation",
28
+ ".docs/05_decisions",
29
+ ".docs/06_review",
30
+ ".docs/07_test",
31
+ ".docs/08_release",
32
+ ".docs/rfc",
33
+ ".codex/skills",
34
+ "tools",
35
+ ]
36
+ require_paths(required_files + required_dirs)
37
+
38
+ lifecycle = load_lifecycle()
39
+ phases = load_phase_contracts()
40
+ load_yaml(".codex/pjsdlc_managed/policies/gates.yaml")
41
+ load_yaml(".codex/pjsdlc_managed/policies/allowed_paths.yaml")
42
+ load_yaml(".codex/pjsdlc_managed/policies/risk_matrix.yaml")
43
+
44
+ current_phase = lifecycle.get("current_phase")
45
+ require(current_phase in phases, f"Lifecycle current_phase is not declared: {current_phase}")
46
+
47
+ for phase_name, contract in phases.items():
48
+ skill = contract.get("skill")
49
+ require(skill, f"{phase_name} missing skill")
50
+ skill_file = repo_path(f".codex/skills/{skill}/SKILL.md")
51
+ require(skill_file.exists(), f"Missing skill file for {phase_name}: {skill_file.relative_to(repo_path('.'))}")
52
+ require("inputs" in contract, f"{phase_name} missing inputs")
53
+ require("outputs" in contract, f"{phase_name} missing outputs")
54
+ require("gates" in contract, f"{phase_name} missing gates")
55
+
56
+ print("Harness scaffold OK")
57
+
58
+
59
+ if __name__ == "__main__":
60
+ run_main(main)
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import (
3
+ make_arg_parser,
4
+ load_plan,
5
+ run_main,
6
+ validate_plan_contract,
7
+ )
8
+
9
+
10
+ def main() -> None:
11
+ parser = make_arg_parser("Validate plan.yaml shape and active task contract")
12
+ parser.add_argument("--allow-open", action="store_true", help="Allow open tasks while validating an in-progress workflow task")
13
+ args = parser.parse_args()
14
+
15
+ data = load_plan()
16
+ tasks = data.get("tasks", [])
17
+ validate_plan_contract(data, allow_open=args.allow_open)
18
+
19
+ suffix = "open tasks allowed" if args.allow_open else "none open"
20
+ print(f"Plan OK: {len(tasks)} active/future task(s), {suffix}")
21
+
22
+
23
+ if __name__ == "__main__":
24
+ run_main(main)
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import load_plan, require, run_main, validate_task_shape
3
+
4
+
5
+ def main() -> None:
6
+ data = load_plan(".codex/state/plan.draft.yaml")
7
+ require("current_phase" not in data, "plan.draft.yaml must not define current_phase; lifecycle.yaml is the single source for current_phase")
8
+ require("current_task_id" not in data, "plan.draft.yaml must not define current_task_id because drafts are not active task state")
9
+ tasks = data.get("tasks", [])
10
+ require(tasks, "plan.draft.yaml must contain at least one task before leaving ARCHITECTING")
11
+ for index, task in enumerate(tasks):
12
+ require(isinstance(task, dict), f"Task draft #{index + 1} must be a mapping")
13
+ validate_task_shape(task, index)
14
+ require(task.get("status") == "pending", f"Draft task {task.get('id')} should start as pending")
15
+ print(f"Task draft OK: {len(tasks)} task(s)")
16
+
17
+
18
+ if __name__ == "__main__":
19
+ run_main(main)
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import (
3
+ combined_text,
4
+ contains_any,
5
+ load_plan,
6
+ markdown_deliverables,
7
+ require,
8
+ run_main,
9
+ validate_plan_contract,
10
+ )
11
+
12
+
13
+ def main() -> None:
14
+ plan = load_plan()
15
+ validate_plan_contract(plan, allow_open=False)
16
+
17
+ docs = markdown_deliverables(".docs/01_product")
18
+ require(docs, "No PRD deliverables found in .docs/01_product/")
19
+ text = combined_text(docs)
20
+ require(contains_any(text, ["acceptance", "验收"]), "PRD must include acceptance criteria")
21
+ require(contains_any(text, ["out of scope", "out of scope", "不做", "边界"]), "PRD must include out-of-scope boundaries")
22
+ require(contains_any(text, ["open questions", "未决", "待确认"]), "PRD must include open questions")
23
+ print(f"PRD artifacts OK: {len(docs)} file(s)")
24
+
25
+
26
+ if __name__ == "__main__":
27
+ run_main(main)