agent-project-sdlc 0.1.19 → 0.1.20

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 (37) hide show
  1. package/README.md +8 -6
  2. package/assets/agents/AGENTS_CORE.md +2 -2
  3. package/assets/docs/README.md +9 -7
  4. package/assets/skills/pjsdlc_architect_design/SKILL.md +2 -0
  5. package/assets/skills/pjsdlc_dev_sprint/SKILL.md +4 -4
  6. package/assets/skills/pjsdlc_manager/SKILL.md +4 -4
  7. package/assets/skills/pjsdlc_pm_prd/SKILL.md +3 -3
  8. package/assets/skills/pjsdlc_release_manager/SKILL.md +2 -0
  9. package/assets/skills/pjsdlc_reviewer/SKILL.md +2 -0
  10. package/assets/skills/pjsdlc_rfc_recalibrate/SKILL.md +2 -0
  11. package/assets/skills/pjsdlc_tester/SKILL.md +2 -2
  12. package/assets/templates/PLAN_TEMPLATE.yaml +9 -6
  13. package/assets/tools/build_doc_overviews.py +152 -0
  14. package/assets/tools/harness_utils.py +858 -0
  15. package/assets/tools/impact_analyzer.py +51 -0
  16. package/assets/tools/run_current_gate.py +29 -0
  17. package/assets/tools/status.py +29 -0
  18. package/assets/tools/transition.py +68 -0
  19. package/assets/tools/validate_allowed_paths.py +44 -0
  20. package/assets/tools/validate_design.py +199 -0
  21. package/assets/tools/validate_dev_state.py +20 -0
  22. package/assets/tools/validate_harness.py +60 -0
  23. package/assets/tools/validate_plan.py +24 -0
  24. package/assets/tools/validate_plan_draft.py +19 -0
  25. package/assets/tools/validate_prd.py +27 -0
  26. package/assets/tools/validate_prompt_language.py +138 -0
  27. package/assets/tools/validate_release_plan.py +37 -0
  28. package/assets/tools/validate_review.py +59 -0
  29. package/assets/tools/validate_rfc.py +105 -0
  30. package/assets/tools/validate_task_docs.py +40 -0
  31. package/assets/tools/validate_test_plan.py +82 -0
  32. package/dist/lib/config.js +1 -0
  33. package/dist/lib/migrations.js +3 -0
  34. package/dist/lib/sync-engine.js +4 -0
  35. package/dist/lib/validators.js +105 -9
  36. package/package.json +1 -1
  37. package/source-mappings.yaml +6 -0
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from harness_utils import ROOT, HarnessError, load_yaml, require, run_main
7
+
8
+
9
+ SKILL_REQUIRED_SECTIONS = ["## 目的", "## 角色提示词", "## 输入", "## 规则", "## 完成检查"]
10
+ ARTIFACT_SKILLS_REQUIRE_SEMANTIC_SLICING = {
11
+ "pjsdlc_pm_prd",
12
+ "pjsdlc_architect_design",
13
+ "pjsdlc_dev_sprint",
14
+ "pjsdlc_implementation_doc",
15
+ "pjsdlc_reviewer",
16
+ "pjsdlc_tester",
17
+ "pjsdlc_release_manager",
18
+ "pjsdlc_rfc_recalibrate",
19
+ }
20
+ SKILL_FORBIDDEN_HEADINGS = [
21
+ "## Purpose",
22
+ "## Required Inputs",
23
+ "## Outputs",
24
+ "## Rules",
25
+ "## Completion Checklist",
26
+ ]
27
+
28
+ MACHINE_IDENTIFIERS = [
29
+ "current_phase",
30
+ "active_skill",
31
+ "allowed_paths",
32
+ "required_gates",
33
+ "implementation_doc",
34
+ "REQUIREMENT_GATHERING",
35
+ "ARCHITECTING",
36
+ "SPRINTING",
37
+ "REVIEWING",
38
+ "TESTING",
39
+ "RELEASING",
40
+ "RFC_RECALIBRATION",
41
+ "BLOCKED",
42
+ "pending",
43
+ "in_progress",
44
+ "done",
45
+ "pending_revision",
46
+ ]
47
+
48
+ REQUIRED_AGENTS_TERMS = [
49
+ "Skill Language Contract",
50
+ "中文解释 + 英文精确标识符",
51
+ ".codex/state/lifecycle.yaml",
52
+ ".codex/state/plan.yaml",
53
+ "make validate-current",
54
+ ]
55
+
56
+ YAML_KEYWORDS = {
57
+ "lifecycle": [
58
+ "project_name",
59
+ "version",
60
+ "current_phase",
61
+ "active_role",
62
+ "active_skill",
63
+ "current_milestone",
64
+ "allowed_next_phases",
65
+ ],
66
+ "plan": [
67
+ "current_task_id",
68
+ "next_task_sequence",
69
+ "tasks",
70
+ ],
71
+ "phase_contracts": ["phases"],
72
+ }
73
+
74
+
75
+ def text(path: Path) -> str:
76
+ return path.read_text(encoding="utf-8")
77
+
78
+
79
+ def validate_agents() -> None:
80
+ content = text(ROOT / "AGENTS.md")
81
+ for term in REQUIRED_AGENTS_TERMS:
82
+ require(term in content, f"AGENTS.md missing skill language contract term: {term}")
83
+ for identifier in MACHINE_IDENTIFIERS:
84
+ require(identifier in content, f"AGENTS.md should preserve machine identifier: {identifier}")
85
+
86
+
87
+ def validate_skills() -> None:
88
+ skill_files = sorted((ROOT / ".codex/skills").glob("*/SKILL.md"))
89
+ require(skill_files, "No workflow skill files found under .codex/skills/")
90
+
91
+ for path in skill_files:
92
+ content = text(path)
93
+ for section in SKILL_REQUIRED_SECTIONS:
94
+ require(section in content, f"{path.relative_to(ROOT)} missing Chinese section: {section}")
95
+ for heading in SKILL_FORBIDDEN_HEADINGS:
96
+ require(heading not in content, f"{path.relative_to(ROOT)} still uses English skill heading: {heading}")
97
+ require("name:" in content and "description:" in content, f"{path.relative_to(ROOT)} must keep frontmatter name/description")
98
+ skill_name = path.parent.name
99
+ if skill_name in ARTIFACT_SKILLS_REQUIRE_SEMANTIC_SLICING:
100
+ require("## 语义切片" in content, f"{path.relative_to(ROOT)} missing semantic slicing section: ## 语义切片")
101
+
102
+
103
+ def validate_skill_template() -> None:
104
+ path = ROOT / ".codex/pjsdlc_managed/templates/SKILL_TEMPLATE.md"
105
+ require(path.exists(), "Missing .codex/pjsdlc_managed/templates/SKILL_TEMPLATE.md")
106
+ content = text(path)
107
+ for section in SKILL_REQUIRED_SECTIONS:
108
+ require(section in content, f"SKILL_TEMPLATE.md missing Chinese section: {section}")
109
+ require("## 语义切片" in content, "SKILL_TEMPLATE.md missing semantic slicing section: ## 语义切片")
110
+ require("current_phase" in content and "make validate-current" in content, "SKILL_TEMPLATE.md must demonstrate English machine identifiers")
111
+
112
+
113
+ def validate_yaml_keys() -> None:
114
+ lifecycle = load_yaml(".codex/state/lifecycle.yaml")
115
+ tasks = load_yaml(".codex/state/plan.yaml")
116
+ phase_contracts = load_yaml(".codex/pjsdlc_managed/policies/phase_contracts.yaml")
117
+
118
+ for key in YAML_KEYWORDS["lifecycle"]:
119
+ require(key in lifecycle, f"lifecycle.yaml key was removed or translated: {key}")
120
+ for key in YAML_KEYWORDS["plan"]:
121
+ require(key in tasks, f"plan.yaml key was removed or translated: {key}")
122
+ for key in YAML_KEYWORDS["phase_contracts"]:
123
+ require(key in phase_contracts, f"phase_contracts.yaml key was removed or translated: {key}")
124
+
125
+
126
+ def main() -> None:
127
+ try:
128
+ validate_agents()
129
+ validate_skills()
130
+ validate_skill_template()
131
+ validate_yaml_keys()
132
+ except HarnessError:
133
+ raise
134
+ print("Skill language contract OK")
135
+
136
+
137
+ if __name__ == "__main__":
138
+ run_main(main)
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import (
3
+ combined_text,
4
+ contains_any,
5
+ load_plan,
6
+ markdown_deliverables,
7
+ repo_path,
8
+ require,
9
+ run_main,
10
+ validate_plan_contract,
11
+ )
12
+
13
+
14
+ CURRENT_RELEASE_REPORT = ".docs/08_release/CURRENT_RELEASE.md"
15
+
16
+
17
+ def release_deliverables():
18
+ current = repo_path(CURRENT_RELEASE_REPORT)
19
+ if current.exists():
20
+ return [current], CURRENT_RELEASE_REPORT
21
+ docs = markdown_deliverables(".docs/08_release")
22
+ return docs, "legacy .docs/08_release/*.md"
23
+
24
+
25
+ def main() -> None:
26
+ validate_plan_contract(load_plan(), allow_open=False)
27
+ docs, source = release_deliverables()
28
+ require(docs, f"Missing current release report: expected {CURRENT_RELEASE_REPORT} or legacy .docs/08_release/*.md")
29
+ text = combined_text(docs)
30
+ require(contains_any(text, ["release", "发布"]), "Current release report must include release notes")
31
+ require(contains_any(text, ["smoke", "冒烟"]), "Current release report must include smoke test evidence")
32
+ require(contains_any(text, ["rollback", "回滚"]), "Current release report must include rollback plan")
33
+ print(f"Current release status OK: {source}")
34
+
35
+
36
+ if __name__ == "__main__":
37
+ run_main(main)
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import contains_any, load_plan, read_text, require, run_main, validate_plan_contract
3
+
4
+ READINESS_FIELDS = [
5
+ "Runnable Entry",
6
+ "Observable Exit",
7
+ "Initialization",
8
+ "Config Contract",
9
+ "Testing Handoff Readiness",
10
+ ]
11
+ RUNTIME_MISMATCH_TERMS = [
12
+ "not deployed",
13
+ "not initialized",
14
+ "local only",
15
+ "localhost only",
16
+ "fake adapter",
17
+ "fake send",
18
+ "未部署",
19
+ "未初始化",
20
+ "只在本地",
21
+ "仅本地",
22
+ "本地跑通",
23
+ ]
24
+
25
+
26
+ def main() -> None:
27
+ validate_plan_contract(load_plan(), allow_open=False)
28
+ text = read_text(".docs/06_review/REVIEW_REPORT.md")
29
+ require(contains_any(text, ["finding", "发现", "风险"]), "Review report must include findings or risks")
30
+ require(contains_any(text, ["test gap", "测试缺口", "coverage"]), "Review report must include test gaps or coverage notes")
31
+ require(
32
+ contains_any(text, ["entry/exit", "entrypoint", "入口", "出口", "runnable", "可运行"]),
33
+ "Review report must assess runnable entry/exit readiness before TESTING",
34
+ )
35
+ lowered = text.lower()
36
+ for field in READINESS_FIELDS:
37
+ field_lower = field.lower()
38
+ has_status = (
39
+ f"{field_lower}: pass" in lowered
40
+ or f"{field_lower}: `pass`" in lowered
41
+ or f"{field_lower}: blocked" in lowered
42
+ or f"{field_lower}: `blocked`" in lowered
43
+ )
44
+ require(has_status, f"Review report must include {field}: PASS/BLOCKED")
45
+ require(
46
+ f"{field_lower}: blocked" not in lowered and f"{field_lower}: `blocked`" not in lowered,
47
+ f"Review readiness is BLOCKED: {field}",
48
+ )
49
+ require(contains_any(text, ["pass", "blocked", "通过", "阻塞"]), "Review report must include PASS/BLOCKED decision")
50
+ if contains_any(lowered, ["decision: pass", "decision: `pass`", "final decision: pass"]):
51
+ require(
52
+ not contains_any(lowered, RUNTIME_MISMATCH_TERMS),
53
+ "Review report cannot PASS while target runtime or handoff evidence is missing or lower-level only",
54
+ )
55
+ print("Review report OK")
56
+
57
+
58
+ if __name__ == "__main__":
59
+ run_main(main)
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+ import re
4
+
5
+ from harness_utils import (
6
+ combined_text,
7
+ contains_any,
8
+ load_plan,
9
+ markdown_deliverables,
10
+ read_text,
11
+ repo_path,
12
+ require,
13
+ run_main,
14
+ validate_plan_contract,
15
+ )
16
+
17
+
18
+ TEST_FACT_SOURCE_REF = re.compile(r"\.docs/07_test/[^\s`,)]+")
19
+ SELF_TEST_TRIGGER_TERMS = [
20
+ "entry/exit",
21
+ "runnable entry",
22
+ "runnable exit",
23
+ "runnable entry/exit",
24
+ "runtime",
25
+ "environment",
26
+ "target_runtime_environment",
27
+ "target runtime",
28
+ "required_gates",
29
+ "gate",
30
+ "handoff",
31
+ "blocker",
32
+ "module key test path",
33
+ "test route",
34
+ "test path",
35
+ "debug path",
36
+ "测试路径",
37
+ "测试链路",
38
+ "自测链路",
39
+ "模块关键测试路径",
40
+ "入口",
41
+ "出口",
42
+ "运行环境",
43
+ "阻塞",
44
+ ]
45
+ SELF_TEST_IMPACT_TERMS = ["development self-test impact", "开发自测影响"]
46
+
47
+
48
+ def superseded_test_docs(docs) -> list[str]:
49
+ paths: set[str] = set()
50
+ for doc in docs:
51
+ for line in doc.read_text(encoding="utf-8").splitlines():
52
+ lowered = line.lower()
53
+ if "superseded" not in lowered and "被替代" not in lowered and "失效" not in lowered:
54
+ continue
55
+ for match in TEST_FACT_SOURCE_REF.findall(line):
56
+ paths.add(match.rstrip(".,;:"))
57
+ return sorted(paths)
58
+
59
+
60
+ def main() -> None:
61
+ validate_plan_contract(load_plan(), allow_open=False)
62
+ docs = markdown_deliverables(".docs/rfc")
63
+ require(docs, "No RFC documents found in .docs/rfc/")
64
+ text = combined_text(docs)
65
+ require(contains_any(text, ["background", "背景"]), "RFC must include background")
66
+ require(contains_any(text, ["product impact", "产品影响"]), "RFC must include product impact")
67
+ require(contains_any(text, ["technical impact", "技术影响"]), "RFC must include technical impact candidates")
68
+ require(contains_any(text, ["regression", "回归"]), "RFC must include regression requirements")
69
+ require(
70
+ contains_any(text, ["test fact source impact", "测试事实源影响"]),
71
+ "RFC must include Test Fact Source Impact",
72
+ )
73
+ index_text = read_text(".docs/INDEX.md")
74
+ for path in superseded_test_docs(docs):
75
+ require(not repo_path(path).exists(), f"Superseded test doc still exists in current facts: {path}")
76
+ require(path not in index_text, f"Superseded test doc still linked from .docs/INDEX.md: {path}")
77
+ statuses = re.findall(r"Status:\s*([A-Z_]+)", text)
78
+ require(statuses, "RFC must include a Status line")
79
+ allowed = {"DRAFT", "APPLIED", "VERIFIED", "ARCHIVED"}
80
+ invalid = [status for status in statuses if status not in allowed]
81
+ require(not invalid, "Invalid RFC status: " + ", ".join(invalid))
82
+ validate_development_self_test_impact(docs)
83
+ print(f"RFC artifacts OK: {len(docs)} file(s)")
84
+
85
+
86
+ def validate_development_self_test_impact(docs) -> None:
87
+ for doc in docs:
88
+ number = rfc_number(doc.name)
89
+ if number is not None and number < 23:
90
+ continue
91
+ text = doc.read_text(encoding="utf-8")
92
+ if contains_any(text, SELF_TEST_TRIGGER_TERMS):
93
+ require(
94
+ contains_any(text, SELF_TEST_IMPACT_TERMS),
95
+ f"{doc.relative_to(repo_path('.')).as_posix()} must include Development Self-Test Impact when RFC changes entry/exit, runtime, gates, handoff, or blockers",
96
+ )
97
+
98
+
99
+ def rfc_number(file_name: str) -> int | None:
100
+ match = re.match(r"^RFC[_-](\d+)", file_name, re.IGNORECASE)
101
+ return int(match.group(1)) if match else None
102
+
103
+
104
+ if __name__ == "__main__":
105
+ run_main(main)
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import contains_any, read_text, repo_path, require, run_main
3
+
4
+
5
+ RUNNABLE_ENTRY_EXIT_TERMS = [
6
+ "runnable entry/exit",
7
+ "entry/exit",
8
+ "entry points",
9
+ "entry point",
10
+ "可运行入口/出口",
11
+ "入口/出口",
12
+ "not applicable",
13
+ ]
14
+
15
+
16
+ def main() -> None:
17
+ index = read_text(".docs/INDEX.md")
18
+ docs_root = repo_path(".docs/04_implementation")
19
+ docs = sorted(
20
+ path for path in docs_root.rglob("*.md")
21
+ if path.name != "overview.md"
22
+ )
23
+ if not docs:
24
+ print("Implementation docs OK: no implementation docs yet")
25
+ return
26
+ for path in docs:
27
+ relative = path.relative_to(repo_path(".")).as_posix()
28
+ doc = relative if relative.startswith(".") else f".{relative}"
29
+ index_path = doc.removeprefix(".docs/")
30
+ require(doc in index or index_path in index, f".docs/INDEX.md does not link implementation doc: {doc}")
31
+ text = read_text(doc)
32
+ require(
33
+ contains_any(text, RUNNABLE_ENTRY_EXIT_TERMS),
34
+ f"Implementation doc must include Runnable Entry/Exit facts or explicit Not applicable: {doc}",
35
+ )
36
+ print(f"Implementation docs OK: {len(docs)} implementation doc(s)")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ run_main(main)
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env python3
2
+ from harness_utils import (
3
+ changed_files,
4
+ contains_any,
5
+ load_lifecycle,
6
+ load_plan,
7
+ repo_path,
8
+ read_text,
9
+ require,
10
+ run_main,
11
+ testing_boundary_errors_for_changed_files,
12
+ validate_plan_contract,
13
+ )
14
+
15
+ TEST_REPORT_PATH = ".docs/07_test/TEST_REPORT.md"
16
+ PLACEHOLDER_TERMS = ["pending", "tbd", "todo", "待填", "待补", "placeholder"]
17
+ MISSING_READINESS_TERMS = [
18
+ "missing entry",
19
+ "missing exit",
20
+ "missing runnable",
21
+ "missing development evidence",
22
+ "no runnable",
23
+ "no entry",
24
+ "no exit",
25
+ "入口缺失",
26
+ "出口缺失",
27
+ "缺少入口",
28
+ "缺少出口",
29
+ "缺少 development evidence",
30
+ "尚未交付",
31
+ "未交付",
32
+ "不存在",
33
+ "not deployed",
34
+ "not initialized",
35
+ "local only",
36
+ "localhost only",
37
+ "fake adapter",
38
+ "fake send",
39
+ "未部署",
40
+ "未初始化",
41
+ "只在本地",
42
+ "仅本地",
43
+ "本地跑通",
44
+ ]
45
+
46
+
47
+ def read_test_report() -> tuple[str, str]:
48
+ require(
49
+ repo_path(TEST_REPORT_PATH).exists(),
50
+ f"Missing test report: expected executed evidence at {TEST_REPORT_PATH}",
51
+ )
52
+ return read_text(TEST_REPORT_PATH), TEST_REPORT_PATH
53
+
54
+
55
+ def main() -> None:
56
+ validate_plan_contract(load_plan(), allow_open=False)
57
+ text, source = read_test_report()
58
+ require(
59
+ not contains_any(text, PLACEHOLDER_TERMS),
60
+ "Test report must contain executed evidence, not pending/TBD/TODO/placeholder content",
61
+ )
62
+ require(contains_any(text, ["matrix", "矩阵"]), "Test report must include a test matrix")
63
+ require(contains_any(text, ["regression", "回归"]), "Test report must include regression evidence")
64
+ require(contains_any(text, ["coverage gap", "覆盖缺口", "gap"]), "Test report must include coverage gaps")
65
+ require(
66
+ contains_any(text, ["entry/exit", "entrypoint", "入口", "出口", "runnable", "可运行"]),
67
+ "Test report must state existing runnable entry/exit coverage or blocker status",
68
+ )
69
+ require(contains_any(text, ["pass", "blocked", "通过", "阻塞"]), "Test report must include PASS/BLOCKED decision")
70
+ if contains_any(text, ["decision\n\npass", "decision: pass", "decision: `pass`", "final decision: pass"]):
71
+ require(
72
+ not contains_any(text, MISSING_READINESS_TERMS),
73
+ "Test report cannot PASS while runnable entry/exit or Development Evidence is missing; use BLOCKED with recovery conditions",
74
+ )
75
+ if load_lifecycle().get("current_phase") == "TESTING":
76
+ for error in testing_boundary_errors_for_changed_files(changed_files()):
77
+ require(False, error)
78
+ print(f"Test report OK: {source}")
79
+
80
+
81
+ if __name__ == "__main__":
82
+ run_main(main)
@@ -15,6 +15,7 @@ export function defaultConfig(root) {
15
15
  { path: harnessPath(root, "pjsdlc_managed", "templates"), strategy: "managed" },
16
16
  { path: harnessPath(root, "pjsdlc_managed", "policies"), strategy: "merge-with-local" },
17
17
  { path: harnessPath(root, "pjsdlc_managed", "make", "sdlc-harness.mk"), strategy: "managed" },
18
+ { path: "tools", strategy: "managed" },
18
19
  { path: ".github/workflows/harness.yml", strategy: "create-if-missing" }
19
20
  ],
20
21
  local_overrides: [
@@ -162,6 +162,9 @@ function migrateManagedFiles(managedFiles, root) {
162
162
  migrated.unshift(makefileEntry);
163
163
  }
164
164
  }
165
+ if (!seen.has("tools")) {
166
+ push({ path: "tools", strategy: "managed" });
167
+ }
165
168
  if (!seen.has(".github/workflows/harness.yml")) {
166
169
  push({ path: ".github/workflows/harness.yml", strategy: "create-if-missing" });
167
170
  }
@@ -49,6 +49,10 @@ async function syncManagedFile(projectRoot, root, managedFile, report) {
49
49
  await syncFile(packageAssetPath("make", "sdlc-harness.mk"), destination, report, "skip-if-missing");
50
50
  return;
51
51
  }
52
+ if (managedFile.path === "tools") {
53
+ await syncTree(packageAssetPath("tools"), destination, report);
54
+ return;
55
+ }
52
56
  if (managedFile.path === ".github/workflows/harness.yml") {
53
57
  await syncGithubWorkflow(packageAssetPath("github", "harness.yml"), destination, managedFile.path, report);
54
58
  return;
@@ -6,8 +6,20 @@ import { listFiles, pathExists, readText } from "./fs.js";
6
6
  import { parseYaml } from "./yaml.js";
7
7
  const execFileAsync = promisify(execFile);
8
8
  const PARALLEL_MODES = new Set(["runtime_managed", "user_orchestrated"]);
9
+ const PARALLEL_TRIGGERS = new Set(["user_requested", "workflow_default"]);
10
+ const PARALLEL_RUNTIME_PROVIDERS = new Set(["codex_native_subagents", "user_orchestrated", "codex_exec_worktree"]);
9
11
  const TASK_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
10
- const PARALLEL_ALLOWED_PHASES = new Set(["REQUIREMENT_GATHERING", "SPRINTING", "TESTING"]);
12
+ const PARALLEL_ALLOWED_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
13
+ const PARALLEL_READ_ONLY_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "REVIEWING", "RELEASING", "RFC_RECALIBRATION"]);
14
+ const PARALLEL_PROTECTED_WRITE_PATTERNS = [
15
+ ".codex/state/**",
16
+ "<harnessRoot>/state/**",
17
+ ".docs/INDEX.md",
18
+ ".docs/**/overview.md",
19
+ ".docs/04_implementation/**",
20
+ ".docs/06_review/**",
21
+ ".docs/08_release/**"
22
+ ];
11
23
  const TASK_STATUSES = new Set(["pending", "in_progress", "done", "blocked", "pending_revision", "cancelled"]);
12
24
  const OPEN_TASK_STATUSES = new Set(["pending", "in_progress", "blocked", "pending_revision"]);
13
25
  const EVIDENCE_LEVELS = new Set(["unit", "local_runtime", "external_provider_live", "deployed_runtime", "business_handoff_ready"]);
@@ -952,11 +964,19 @@ function validateParallelExecutionContract(plan, currentPhase, errors) {
952
964
  }
953
965
  if (contract.enabled !== true)
954
966
  errors.push("parallel_execution.enabled must be true when present");
955
- if (contract.trigger !== "user_requested")
956
- errors.push('parallel_execution.trigger must be "user_requested"');
967
+ if (!PARALLEL_TRIGGERS.has(String(contract.trigger ?? ""))) {
968
+ errors.push("parallel_execution.trigger must be user_requested or workflow_default");
969
+ }
957
970
  if (!PARALLEL_MODES.has(String(contract.mode ?? ""))) {
958
971
  errors.push("parallel_execution.mode must be runtime_managed or user_orchestrated");
959
972
  }
973
+ const provider = parallelRuntimeProvider(contract, errors);
974
+ if (provider && !PARALLEL_RUNTIME_PROVIDERS.has(provider)) {
975
+ errors.push("parallel_execution.runtime.provider must be codex_native_subagents, user_orchestrated, or codex_exec_worktree");
976
+ }
977
+ if (contract.trigger === "workflow_default" && provider !== "codex_native_subagents") {
978
+ errors.push('parallel_execution.runtime.provider must be "codex_native_subagents" when trigger is workflow_default');
979
+ }
960
980
  if ("phase" in contract) {
961
981
  errors.push("parallel_execution must not define phase; lifecycle.yaml is the single source for current_phase");
962
982
  }
@@ -964,7 +984,7 @@ function validateParallelExecutionContract(plan, currentPhase, errors) {
964
984
  errors.push("parallel_execution must not define linked_task_id; use plan.yaml current_task_id");
965
985
  }
966
986
  if (!PARALLEL_ALLOWED_PHASES.has(currentPhase)) {
967
- errors.push("parallel_execution is only supported during REQUIREMENT_GATHERING, SPRINTING, or TESTING");
987
+ errors.push("parallel_execution is only supported during REQUIREMENT_GATHERING, ARCHITECTING, SPRINTING, REVIEWING, TESTING, RELEASING, or RFC_RECALIBRATION");
968
988
  }
969
989
  if (contract.coordinator !== "main_agent")
970
990
  errors.push('parallel_execution.coordinator must be "main_agent"');
@@ -977,6 +997,7 @@ function validateParallelExecutionContract(plan, currentPhase, errors) {
977
997
  }
978
998
  else {
979
999
  const seen = new Set();
1000
+ const writeOwnedPaths = [];
980
1001
  workers.forEach((worker, index) => {
981
1002
  const prefix = `parallel_execution.workers[${index}]`;
982
1003
  if (!isRecord(worker)) {
@@ -1003,18 +1024,36 @@ function validateParallelExecutionContract(plan, currentPhase, errors) {
1003
1024
  if (Array.isArray(worker.required_gates) && worker.required_gates.length === 0) {
1004
1025
  errors.push(`${prefix}.required_gates must not be empty`);
1005
1026
  }
1027
+ if (PARALLEL_READ_ONLY_PHASES.has(currentPhase) && worker.writes_repo !== false) {
1028
+ errors.push(`${prefix}.writes_repo must be false during ${currentPhase}`);
1029
+ }
1006
1030
  if (worker.writes_repo === true) {
1007
- if (typeof worker.branch !== "string" || !worker.branch.trim()) {
1008
- errors.push(`${prefix}.branch is required when writes_repo is true`);
1009
- }
1010
- if (typeof worker.worktree !== "string" || !worker.worktree.trim()) {
1011
- errors.push(`${prefix}.worktree is required when writes_repo is true`);
1031
+ if (provider !== "codex_native_subagents") {
1032
+ if (typeof worker.branch !== "string" || !worker.branch.trim()) {
1033
+ errors.push(`${prefix}.branch is required when writes_repo is true outside codex_native_subagents runtime`);
1034
+ }
1035
+ if (typeof worker.worktree !== "string" || !worker.worktree.trim()) {
1036
+ errors.push(`${prefix}.worktree is required when writes_repo is true outside codex_native_subagents runtime`);
1037
+ }
1012
1038
  }
1013
1039
  if (!Array.isArray(worker.owned_paths) || worker.owned_paths.length === 0) {
1014
1040
  errors.push(`${prefix}.owned_paths must not be empty when writes_repo is true`);
1015
1041
  }
1042
+ validateParallelWorkerPathLock(plan, worker, index, errors);
1043
+ for (const owned of stringArray(worker.owned_paths).map(normalizeParallelPattern)) {
1044
+ writeOwnedPaths.push({ index, path: owned });
1045
+ }
1016
1046
  }
1017
1047
  });
1048
+ for (let left = 0; left < writeOwnedPaths.length; left += 1) {
1049
+ for (let right = left + 1; right < writeOwnedPaths.length; right += 1) {
1050
+ const leftOwned = writeOwnedPaths[left];
1051
+ const rightOwned = writeOwnedPaths[right];
1052
+ if (globPatternsOverlap(leftOwned.path, rightOwned.path)) {
1053
+ errors.push(`parallel_execution write worker owned_paths must not overlap: workers[${leftOwned.index}] ${leftOwned.path} vs workers[${rightOwned.index}] ${rightOwned.path}`);
1054
+ }
1055
+ }
1056
+ }
1018
1057
  }
1019
1058
  const integration = contract.integration;
1020
1059
  if (!isRecord(integration)) {
@@ -1033,6 +1072,63 @@ function validateParallelExecutionContract(plan, currentPhase, errors) {
1033
1072
  errors.push("parallel_execution.integration.fact_source_updates must be a non-empty list");
1034
1073
  }
1035
1074
  }
1075
+ function parallelRuntimeProvider(contract, errors) {
1076
+ const runtime = contract.runtime;
1077
+ if (runtime === undefined || runtime === null)
1078
+ return "";
1079
+ if (!isRecord(runtime)) {
1080
+ errors.push("parallel_execution.runtime must be a mapping when present");
1081
+ return "";
1082
+ }
1083
+ return String(runtime.provider ?? "");
1084
+ }
1085
+ function validateParallelWorkerPathLock(plan, worker, index, errors) {
1086
+ const currentTask = currentPlanTask(plan);
1087
+ if (!currentTask)
1088
+ return;
1089
+ const taskAllowed = stringArray(currentTask.allowed_paths).map(normalizeParallelPattern);
1090
+ const workerOwned = stringArray(worker.owned_paths).map(normalizeParallelPattern);
1091
+ const workerForbidden = stringArray(worker.forbidden_paths).map(normalizeParallelPattern);
1092
+ const protectedPatterns = PARALLEL_PROTECTED_WRITE_PATTERNS.map(normalizeParallelPattern);
1093
+ for (const owned of workerOwned) {
1094
+ if (!matchesAny(owned, taskAllowed)) {
1095
+ errors.push(`parallel_execution.workers[${index}].owned_paths must be within current task allowed_paths: ${owned}`);
1096
+ }
1097
+ for (const forbidden of [...workerForbidden, ...protectedPatterns]) {
1098
+ if (globPatternsOverlap(owned, forbidden)) {
1099
+ errors.push(`parallel_execution.workers[${index}].owned_paths must not overlap forbidden paths: ${owned} vs ${forbidden}`);
1100
+ }
1101
+ }
1102
+ }
1103
+ }
1104
+ function currentPlanTask(plan) {
1105
+ const currentTaskId = String(plan.current_task_id ?? "");
1106
+ const tasks = Array.isArray(plan.tasks) ? plan.tasks.filter(isRecord) : [];
1107
+ return tasks.find((task) => String(task.id ?? "") === currentTaskId);
1108
+ }
1109
+ function stringArray(value) {
1110
+ return Array.isArray(value) ? value.map((item) => String(item)) : [];
1111
+ }
1112
+ function normalizeParallelPattern(pattern) {
1113
+ return pattern.replace(/\\/g, "/").replaceAll("<harnessRoot>", ".codex");
1114
+ }
1115
+ function globPrefix(pattern) {
1116
+ const normalized = normalizeParallelPattern(pattern);
1117
+ const positions = ["*", "[", "?"].map((token) => normalized.indexOf(token)).filter((index) => index >= 0);
1118
+ const prefix = positions.length > 0 ? normalized.slice(0, Math.min(...positions)) : normalized;
1119
+ return prefix.replace(/\/+$/, "");
1120
+ }
1121
+ function globPatternsOverlap(left, right) {
1122
+ const leftClean = normalizeParallelPattern(left);
1123
+ const rightClean = normalizeParallelPattern(right);
1124
+ if (matchesGlob(leftClean, rightClean) || matchesGlob(rightClean, leftClean))
1125
+ return true;
1126
+ const leftPrefix = globPrefix(leftClean);
1127
+ const rightPrefix = globPrefix(rightClean);
1128
+ if (!leftPrefix || !rightPrefix)
1129
+ return leftPrefix === rightPrefix;
1130
+ return leftPrefix === rightPrefix || leftPrefix.startsWith(`${rightPrefix}/`) || rightPrefix.startsWith(`${leftPrefix}/`);
1131
+ }
1036
1132
  async function validateChangedPaths(projectRoot, plan, allowOpen) {
1037
1133
  if (!allowOpen)
1038
1134
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-project-sdlc",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "CLI and canonical assets for the AI SDLC Harness workflow.",
5
5
  "type": "module",
6
6
  "bin": {