agent-project-sdlc 0.1.24 → 0.1.26
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/README.md +35 -10
- package/assets/agents/AGENTS_CORE.md +14 -9
- package/assets/docs/README.md +64 -11
- package/assets/make/sdlc-harness.mk +5 -1
- package/assets/policies/allowed_paths.yaml +9 -0
- package/assets/policies/gates.yaml +6 -0
- package/assets/policies/phase_contracts.yaml +49 -0
- package/assets/skills/pjsdlc_architect_design/SKILL.md +14 -8
- package/assets/skills/pjsdlc_dev_sprint/SKILL.md +8 -3
- package/assets/skills/pjsdlc_implementation_doc/SKILL.md +9 -4
- package/assets/skills/pjsdlc_manager/SKILL.md +17 -16
- package/assets/skills/pjsdlc_reviewer/SKILL.md +6 -1
- package/assets/skills/pjsdlc_rfc_recalibrate/SKILL.md +8 -5
- package/assets/skills/pjsdlc_tester/SKILL.md +12 -4
- package/assets/skills/pjsdlc_uiux_design/SKILL.md +76 -0
- package/assets/templates/PLAN_TEMPLATE.yaml +4 -0
- package/assets/templates/REVIEW_TEMPLATE.md +14 -4
- package/assets/templates/RFC_TEMPLATE.md +13 -5
- package/assets/templates/TECH_DESIGN_TEMPLATE.md +18 -10
- package/assets/templates/TEST_CASES_TEMPLATE.md +5 -3
- package/assets/templates/TEST_STRATEGY_TEMPLATE.md +4 -0
- package/assets/templates/UI_UX_DESIGN_TEMPLATE.md +67 -0
- package/assets/tools/harness_utils.py +92 -18
- package/assets/tools/transition.py +2 -1
- package/assets/tools/validate_allowed_paths.py +2 -2
- package/assets/tools/validate_design.py +56 -3
- package/assets/tools/validate_dev_state.py +1 -1
- package/assets/tools/validate_harness.py +17 -14
- package/assets/tools/validate_plan_draft.py +1 -1
- package/assets/tools/validate_prompt_language.py +17 -17
- package/assets/tools/validate_rfc.py +31 -0
- package/assets/tools/validate_test_plan.py +118 -1
- package/assets/tools/validate_uiux_design.py +101 -0
- package/dist/commands/index.js +5 -1
- package/dist/commands/inspect-workflow.d.ts +1 -0
- package/dist/commands/inspect-workflow.js +71 -0
- package/dist/lib/harness-root.js +5 -5
- package/dist/lib/init.js +7 -3
- package/dist/lib/validators.js +341 -27
- package/dist/lib/workflow-inspector.d.ts +35 -0
- package/dist/lib/workflow-inspector.js +340 -0
- package/package.json +2 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
from harness_utils import (
|
|
3
|
+
harness_path,
|
|
3
4
|
load_lifecycle,
|
|
4
5
|
load_phase_contract_data,
|
|
5
6
|
load_phase_contracts,
|
|
@@ -13,25 +14,27 @@ from harness_utils import (
|
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def main() -> None:
|
|
17
|
+
root = harness_path()
|
|
16
18
|
required_files = [
|
|
17
19
|
"AGENTS.md",
|
|
18
20
|
"Makefile",
|
|
19
21
|
".docs/INDEX.md",
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
22
|
+
harness_path("state", "lifecycle.yaml"),
|
|
23
|
+
harness_path("state", "plan.yaml"),
|
|
24
|
+
harness_path("state", "plan.draft.yaml"),
|
|
25
|
+
harness_path("state", "memory.md"),
|
|
26
|
+
harness_path("pjsdlc_managed", "templates", "PLAN_TEMPLATE.yaml"),
|
|
27
|
+
harness_path("pjsdlc_managed", "policies", "phase_contracts.yaml"),
|
|
28
|
+
harness_path("pjsdlc_managed", "policies", "gates.yaml"),
|
|
29
|
+
harness_path("pjsdlc_managed", "policies", "allowed_paths.yaml"),
|
|
30
|
+
harness_path("pjsdlc_managed", "policies", "risk_matrix.yaml"),
|
|
29
31
|
"tools/build_doc_overviews.py",
|
|
30
32
|
"tools/validate_plan.py",
|
|
31
33
|
]
|
|
32
34
|
required_dirs = [
|
|
33
35
|
".docs/00_raw",
|
|
34
36
|
".docs/01_product",
|
|
37
|
+
".docs/02_experience",
|
|
35
38
|
".docs/02_architecture",
|
|
36
39
|
".docs/03_tech_plan",
|
|
37
40
|
".docs/04_implementation",
|
|
@@ -41,7 +44,7 @@ def main() -> None:
|
|
|
41
44
|
".docs/08_release",
|
|
42
45
|
".docs/09_runbooks",
|
|
43
46
|
".docs/rfc",
|
|
44
|
-
"
|
|
47
|
+
harness_path("skills"),
|
|
45
48
|
"tools",
|
|
46
49
|
]
|
|
47
50
|
require_paths(required_files + required_dirs)
|
|
@@ -49,9 +52,9 @@ def main() -> None:
|
|
|
49
52
|
lifecycle = load_lifecycle()
|
|
50
53
|
phase_contract_data = load_phase_contract_data()
|
|
51
54
|
phases = load_phase_contracts()
|
|
52
|
-
load_yaml("
|
|
53
|
-
load_yaml("
|
|
54
|
-
load_yaml("
|
|
55
|
+
load_yaml(harness_path("pjsdlc_managed", "policies", "gates.yaml"))
|
|
56
|
+
load_yaml(harness_path("pjsdlc_managed", "policies", "allowed_paths.yaml"))
|
|
57
|
+
load_yaml(harness_path("pjsdlc_managed", "policies", "risk_matrix.yaml"))
|
|
55
58
|
|
|
56
59
|
current_phase = lifecycle.get("current_phase")
|
|
57
60
|
require(current_phase in phases, f"Lifecycle current_phase is not declared: {current_phase}")
|
|
@@ -61,7 +64,7 @@ def main() -> None:
|
|
|
61
64
|
for phase_name, contract in phases.items():
|
|
62
65
|
skill = contract.get("skill")
|
|
63
66
|
require(skill, f"{phase_name} missing skill")
|
|
64
|
-
skill_file = repo_path(f"
|
|
67
|
+
skill_file = repo_path(f"{root}/skills/{skill}/SKILL.md")
|
|
65
68
|
require(skill_file.exists(), f"Missing skill file for {phase_name}: {skill_file.relative_to(repo_path('.'))}")
|
|
66
69
|
require("inputs" in contract, f"{phase_name} missing inputs")
|
|
67
70
|
require("outputs" in contract, f"{phase_name} missing outputs")
|
|
@@ -3,7 +3,7 @@ from harness_utils import load_plan, require, run_main, validate_task_shape
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
def main() -> None:
|
|
6
|
-
data = load_plan("
|
|
6
|
+
data = load_plan("<harnessRoot>/state/plan.draft.yaml")
|
|
7
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
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
9
|
tasks = data.get("tasks", [])
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
-
from harness_utils import ROOT, HarnessError, load_yaml, require, run_main
|
|
6
|
+
from harness_utils import ROOT, HarnessError, harness_path, load_yaml, require, run_main
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
SKILL_REQUIRED_SECTIONS = ["## 目的", "## 角色提示词", "## 输入", "## 规则", "## 完成检查"]
|
|
@@ -45,14 +45,6 @@ MACHINE_IDENTIFIERS = [
|
|
|
45
45
|
"pending_revision",
|
|
46
46
|
]
|
|
47
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
48
|
YAML_KEYWORDS = {
|
|
57
49
|
"lifecycle": [
|
|
58
50
|
"project_name",
|
|
@@ -78,15 +70,23 @@ def text(path: Path) -> str:
|
|
|
78
70
|
|
|
79
71
|
def validate_agents() -> None:
|
|
80
72
|
content = text(ROOT / "AGENTS.md")
|
|
81
|
-
|
|
73
|
+
required_agents_terms = [
|
|
74
|
+
"Skill Language Contract",
|
|
75
|
+
"中文解释 + 英文精确标识符",
|
|
76
|
+
harness_path("state", "lifecycle.yaml"),
|
|
77
|
+
harness_path("state", "plan.yaml"),
|
|
78
|
+
"make validate-current",
|
|
79
|
+
]
|
|
80
|
+
for term in required_agents_terms:
|
|
82
81
|
require(term in content, f"AGENTS.md missing skill language contract term: {term}")
|
|
83
82
|
for identifier in MACHINE_IDENTIFIERS:
|
|
84
83
|
require(identifier in content, f"AGENTS.md should preserve machine identifier: {identifier}")
|
|
85
84
|
|
|
86
85
|
|
|
87
86
|
def validate_skills() -> None:
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
skill_root = ROOT / harness_path("skills")
|
|
88
|
+
skill_files = sorted(skill_root.glob("*/SKILL.md"))
|
|
89
|
+
require(skill_files, f"No workflow skill files found under {skill_root.relative_to(ROOT)}/")
|
|
90
90
|
|
|
91
91
|
for path in skill_files:
|
|
92
92
|
content = text(path)
|
|
@@ -101,8 +101,8 @@ def validate_skills() -> None:
|
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
def validate_skill_template() -> None:
|
|
104
|
-
path = ROOT / "
|
|
105
|
-
require(path.exists(), "Missing .
|
|
104
|
+
path = ROOT / harness_path("pjsdlc_managed", "templates", "SKILL_TEMPLATE.md")
|
|
105
|
+
require(path.exists(), f"Missing {path.relative_to(ROOT)}")
|
|
106
106
|
content = text(path)
|
|
107
107
|
for section in SKILL_REQUIRED_SECTIONS:
|
|
108
108
|
require(section in content, f"SKILL_TEMPLATE.md missing Chinese section: {section}")
|
|
@@ -111,9 +111,9 @@ def validate_skill_template() -> None:
|
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def validate_yaml_keys() -> None:
|
|
114
|
-
lifecycle = load_yaml("
|
|
115
|
-
tasks = load_yaml("
|
|
116
|
-
phase_contracts = load_yaml("
|
|
114
|
+
lifecycle = load_yaml(harness_path("state", "lifecycle.yaml"))
|
|
115
|
+
tasks = load_yaml(harness_path("state", "plan.yaml"))
|
|
116
|
+
phase_contracts = load_yaml(harness_path("pjsdlc_managed", "policies", "phase_contracts.yaml"))
|
|
117
117
|
|
|
118
118
|
for key in YAML_KEYWORDS["lifecycle"]:
|
|
119
119
|
require(key in lifecycle, f"lifecycle.yaml key was removed or translated: {key}")
|
|
@@ -48,6 +48,23 @@ SELF_TEST_TRIGGER_TERMS = [
|
|
|
48
48
|
"阻塞",
|
|
49
49
|
]
|
|
50
50
|
SELF_TEST_IMPACT_TERMS = ["development self-test impact", "开发自测影响"]
|
|
51
|
+
UI_UX_TRIGGER_TERMS = [
|
|
52
|
+
"ui/ux",
|
|
53
|
+
"ux",
|
|
54
|
+
"screen",
|
|
55
|
+
"interaction",
|
|
56
|
+
"design.md",
|
|
57
|
+
"frontend",
|
|
58
|
+
"browser",
|
|
59
|
+
"page",
|
|
60
|
+
"visual",
|
|
61
|
+
"体验",
|
|
62
|
+
"屏幕",
|
|
63
|
+
"交互",
|
|
64
|
+
"视觉",
|
|
65
|
+
"前端",
|
|
66
|
+
]
|
|
67
|
+
UI_UX_IMPACT_TERMS = ["ui/ux impact", "体验影响"]
|
|
51
68
|
|
|
52
69
|
|
|
53
70
|
def superseded_test_docs(docs) -> list[str]:
|
|
@@ -85,6 +102,7 @@ def main() -> None:
|
|
|
85
102
|
invalid = [status for status in statuses if status not in allowed]
|
|
86
103
|
require(not invalid, "Invalid RFC status: " + ", ".join(invalid))
|
|
87
104
|
validate_development_self_test_impact(docs)
|
|
105
|
+
validate_uiux_impact(docs)
|
|
88
106
|
print(f"RFC artifacts OK: {len(docs)} file(s)")
|
|
89
107
|
|
|
90
108
|
|
|
@@ -101,6 +119,19 @@ def validate_development_self_test_impact(docs) -> None:
|
|
|
101
119
|
)
|
|
102
120
|
|
|
103
121
|
|
|
122
|
+
def validate_uiux_impact(docs) -> None:
|
|
123
|
+
for doc in docs:
|
|
124
|
+
number = rfc_number(doc.name)
|
|
125
|
+
if number is not None and number < 27:
|
|
126
|
+
continue
|
|
127
|
+
text = doc.read_text(encoding="utf-8")
|
|
128
|
+
if contains_any(text, UI_UX_TRIGGER_TERMS):
|
|
129
|
+
require(
|
|
130
|
+
contains_any(text, UI_UX_IMPACT_TERMS),
|
|
131
|
+
f"{doc.relative_to(repo_path('.')).as_posix()} must include UI/UX Impact when RFC changes experience docs, screen contracts, DESIGN.md, frontend, or browser behavior",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
104
135
|
def rfc_number(file_name: str) -> int | None:
|
|
105
136
|
match = re.match(r"^RFC[_-](\d+)", file_name, re.IGNORECASE)
|
|
106
137
|
return int(match.group(1)) if match else None
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
import re
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
2
5
|
from harness_utils import (
|
|
3
6
|
changed_files,
|
|
4
7
|
contains_any,
|
|
@@ -13,7 +16,9 @@ from harness_utils import (
|
|
|
13
16
|
)
|
|
14
17
|
|
|
15
18
|
TEST_REPORT_PATH = ".docs/07_test/TEST_REPORT.md"
|
|
19
|
+
TEST_CASES_PATH = ".docs/07_test/TEST_CASES.md"
|
|
16
20
|
PLACEHOLDER_TERMS = ["pending", "tbd", "todo", "待填", "待补", "placeholder"]
|
|
21
|
+
CASE_ID_RE = re.compile(r"\bTC-\d{3,}\b")
|
|
17
22
|
MISSING_READINESS_TERMS = [
|
|
18
23
|
"missing entry",
|
|
19
24
|
"missing exit",
|
|
@@ -44,6 +49,10 @@ MISSING_READINESS_TERMS = [
|
|
|
44
49
|
]
|
|
45
50
|
|
|
46
51
|
|
|
52
|
+
def as_list(value) -> list:
|
|
53
|
+
return value if isinstance(value, list) else []
|
|
54
|
+
|
|
55
|
+
|
|
47
56
|
def read_test_report() -> tuple[str, str]:
|
|
48
57
|
require(
|
|
49
58
|
repo_path(TEST_REPORT_PATH).exists(),
|
|
@@ -52,8 +61,115 @@ def read_test_report() -> tuple[str, str]:
|
|
|
52
61
|
return read_text(TEST_REPORT_PATH), TEST_REPORT_PATH
|
|
53
62
|
|
|
54
63
|
|
|
64
|
+
def test_case_refs(text: str) -> list[str]:
|
|
65
|
+
return sorted(set(CASE_ID_RE.findall(text)))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def plan_references_test_cases(plan: dict) -> bool:
|
|
69
|
+
for task in as_list(plan.get("tasks")):
|
|
70
|
+
if not isinstance(task, dict):
|
|
71
|
+
continue
|
|
72
|
+
if str(task.get("phase") or "") != "TESTING":
|
|
73
|
+
continue
|
|
74
|
+
refs = [str(item) for item in as_list(task.get("result_docs"))]
|
|
75
|
+
if TEST_CASES_PATH in refs:
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def split_markdown_row(line: str) -> list[str]:
|
|
81
|
+
return [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_separator_row(line: str) -> bool:
|
|
85
|
+
stripped = line.strip()
|
|
86
|
+
return stripped.startswith("|") and set(stripped.replace("|", "").strip()) <= {"-", ":", " "}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_table_header(lines: list[str], row_index: int) -> Optional[list[str]]:
|
|
90
|
+
for index in range(row_index - 1, 0, -1):
|
|
91
|
+
if not is_separator_row(lines[index]):
|
|
92
|
+
continue
|
|
93
|
+
header = lines[index - 1]
|
|
94
|
+
if header.strip().startswith("|"):
|
|
95
|
+
return split_markdown_row(header)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def header_index(headers: list[str], *terms: str) -> Optional[int]:
|
|
100
|
+
lowered = [header.lower() for header in headers]
|
|
101
|
+
for index, header in enumerate(lowered):
|
|
102
|
+
if any(term in header for term in terms):
|
|
103
|
+
return index
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def required_cell(cells: list[str], index: Optional[int]) -> str:
|
|
108
|
+
if index is None or index >= len(cells):
|
|
109
|
+
return ""
|
|
110
|
+
return cells[index].strip()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def validate_test_cases(text: str, report_text: str) -> list[str]:
|
|
114
|
+
errors: list[str] = []
|
|
115
|
+
if contains_any(text, PLACEHOLDER_TERMS):
|
|
116
|
+
errors.append("TEST_CASES.md must not contain pending/TBD/TODO/placeholder content")
|
|
117
|
+
|
|
118
|
+
lines = text.splitlines()
|
|
119
|
+
rows: list[tuple[int, list[str], list[str]]] = []
|
|
120
|
+
for index, line in enumerate(lines):
|
|
121
|
+
if not line.strip().startswith("|") or not CASE_ID_RE.search(line):
|
|
122
|
+
continue
|
|
123
|
+
header = find_table_header(lines, index)
|
|
124
|
+
if header is None:
|
|
125
|
+
errors.append("TEST_CASES.md cases must be listed in a Markdown table with headers")
|
|
126
|
+
continue
|
|
127
|
+
rows.append((index + 1, header, split_markdown_row(line)))
|
|
128
|
+
|
|
129
|
+
ids = [CASE_ID_RE.search("|".join(cells)).group(0) for _, _, cells in rows if CASE_ID_RE.search("|".join(cells))]
|
|
130
|
+
if not ids:
|
|
131
|
+
errors.append("TEST_CASES.md must include at least one TC-* case")
|
|
132
|
+
duplicates = sorted({case_id for case_id in ids if ids.count(case_id) > 1})
|
|
133
|
+
if duplicates:
|
|
134
|
+
errors.append(f"TEST_CASES.md Case ID must be unique: {', '.join(duplicates)}")
|
|
135
|
+
|
|
136
|
+
for line_number, headers, cells in rows:
|
|
137
|
+
requirement = header_index(headers, "requirement", "risk", "需求", "风险")
|
|
138
|
+
runnable_entry = header_index(headers, "runnable entry", "runnable", "entry", "入口")
|
|
139
|
+
steps = header_index(headers, "steps", "步骤")
|
|
140
|
+
expected_exit = header_index(headers, "expected exit", "expected result", "expected", "预期", "出口")
|
|
141
|
+
missing = []
|
|
142
|
+
if not required_cell(cells, requirement):
|
|
143
|
+
missing.append("Requirement / Risk Ref")
|
|
144
|
+
if not required_cell(cells, runnable_entry):
|
|
145
|
+
missing.append("Runnable Entry")
|
|
146
|
+
if not required_cell(cells, steps):
|
|
147
|
+
missing.append("Steps")
|
|
148
|
+
if not required_cell(cells, expected_exit):
|
|
149
|
+
missing.append("Expected Exit")
|
|
150
|
+
if missing:
|
|
151
|
+
errors.append(f"TEST_CASES.md row {line_number} missing required case fields: {', '.join(missing)}")
|
|
152
|
+
|
|
153
|
+
refs = test_case_refs(report_text)
|
|
154
|
+
missing_refs = sorted(set(refs) - set(ids))
|
|
155
|
+
if missing_refs:
|
|
156
|
+
errors.append(f"TEST_REPORT.md references case IDs not found in TEST_CASES.md: {', '.join(missing_refs)}")
|
|
157
|
+
return errors
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def validate_test_cases_if_needed(plan: dict, report_text: str) -> None:
|
|
161
|
+
cases_path = repo_path(TEST_CASES_PATH)
|
|
162
|
+
should_validate = bool(test_case_refs(report_text)) or plan_references_test_cases(plan) or cases_path.exists()
|
|
163
|
+
if not should_validate:
|
|
164
|
+
return
|
|
165
|
+
require(cases_path.exists(), f"Missing test cases: expected {TEST_CASES_PATH} because TEST_REPORT.md or current TESTING task references test cases")
|
|
166
|
+
for error in validate_test_cases(read_text(TEST_CASES_PATH), report_text):
|
|
167
|
+
require(False, error)
|
|
168
|
+
|
|
169
|
+
|
|
55
170
|
def main() -> None:
|
|
56
|
-
|
|
171
|
+
plan = load_plan()
|
|
172
|
+
validate_plan_contract(plan, allow_open=False)
|
|
57
173
|
text, source = read_test_report()
|
|
58
174
|
require(
|
|
59
175
|
not contains_any(text, PLACEHOLDER_TERMS),
|
|
@@ -72,6 +188,7 @@ def main() -> None:
|
|
|
72
188
|
not contains_any(text, MISSING_READINESS_TERMS),
|
|
73
189
|
"Test report cannot PASS while runnable entry/exit or Development Evidence is missing; use BLOCKED with recovery conditions",
|
|
74
190
|
)
|
|
191
|
+
validate_test_cases_if_needed(plan, text)
|
|
75
192
|
if load_lifecycle().get("current_phase") == "TESTING":
|
|
76
193
|
for error in testing_boundary_errors_for_changed_files(changed_files()):
|
|
77
194
|
require(False, error)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from harness_utils import (
|
|
6
|
+
contains_any,
|
|
7
|
+
load_lifecycle,
|
|
8
|
+
load_plan,
|
|
9
|
+
markdown_deliverables,
|
|
10
|
+
repo_path,
|
|
11
|
+
run_main,
|
|
12
|
+
require,
|
|
13
|
+
validate_plan_contract,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DESIGN_MD = "DESIGN.md"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> None:
|
|
21
|
+
lifecycle = load_lifecycle()
|
|
22
|
+
plan = load_plan()
|
|
23
|
+
validate_plan_contract(plan, allow_open=lifecycle.get("current_phase") != "UI_UX_DESIGNING")
|
|
24
|
+
|
|
25
|
+
docs = markdown_deliverables(".docs/02_experience")
|
|
26
|
+
require(docs, "No UI/UX deliverables found in .docs/02_experience/")
|
|
27
|
+
visual_ui = False
|
|
28
|
+
for doc in docs:
|
|
29
|
+
text = doc.read_text(encoding="utf-8")
|
|
30
|
+
relative = doc.relative_to(repo_path(".")).as_posix()
|
|
31
|
+
not_applicable = contains_any(text, ["applicability: not_applicable", "applicability: `not_applicable`"])
|
|
32
|
+
visual_ui = visual_ui or contains_any(text, ["applicability: visual_ui", "applicability: `visual_ui`"])
|
|
33
|
+
if not_applicable:
|
|
34
|
+
continue
|
|
35
|
+
require(contains_any(text, ["prd", "requirement", "需求"]), f"{relative} must cite PRD and requirement IDs")
|
|
36
|
+
require(contains_any(text, ["user journey", "user journeys", "用户旅程"]), f"{relative} must include user journeys")
|
|
37
|
+
require(contains_any(text, ["handoff matrix", "交接矩阵"]), f"{relative} must include a handoff matrix")
|
|
38
|
+
require(
|
|
39
|
+
contains_any(text, ["loading", "empty", "error", "success", "permission", "加载", "空状态", "错误", "成功", "权限"]),
|
|
40
|
+
f"{relative} screen contracts must cover applicable loading/empty/error/success/permission states",
|
|
41
|
+
)
|
|
42
|
+
require(contains_any(text, ["responsive", "breakpoint", "响应式", "断点"]), f"{relative} must include responsive acceptance")
|
|
43
|
+
require(
|
|
44
|
+
contains_any(text, ["accessibility", "a11y", "focus", "keyboard", "touch", "无障碍", "焦点", "键盘", "触控"]),
|
|
45
|
+
f"{relative} must include accessibility/focus/keyboard/touch expectations",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if visual_ui:
|
|
49
|
+
design_path = repo_path(DESIGN_MD)
|
|
50
|
+
require(design_path.exists(), "visual UI experience requires root DESIGN.md")
|
|
51
|
+
validate_design_md()
|
|
52
|
+
|
|
53
|
+
print(f"UI/UX artifacts OK: {len(docs)} experience deliverable(s)")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_design_md() -> None:
|
|
57
|
+
command = designmd_command()
|
|
58
|
+
try:
|
|
59
|
+
proc = subprocess.run(
|
|
60
|
+
command + ["lint", DESIGN_MD],
|
|
61
|
+
cwd=repo_path("."),
|
|
62
|
+
text=True,
|
|
63
|
+
capture_output=True,
|
|
64
|
+
check=False,
|
|
65
|
+
)
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
require(False, "DESIGN.md linter not found; install @google/design.md or run npm install")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
output = (proc.stdout or "").strip()
|
|
71
|
+
diagnostics = None
|
|
72
|
+
if output:
|
|
73
|
+
try:
|
|
74
|
+
diagnostics = json.loads(output)
|
|
75
|
+
except json.JSONDecodeError:
|
|
76
|
+
diagnostics = None
|
|
77
|
+
|
|
78
|
+
errors = []
|
|
79
|
+
if isinstance(diagnostics, dict):
|
|
80
|
+
summary = diagnostics.get("summary") if isinstance(diagnostics.get("summary"), dict) else {}
|
|
81
|
+
if int(summary.get("errors") or 0) > 0:
|
|
82
|
+
errors.append("DESIGN.md linter reported errors")
|
|
83
|
+
for finding in diagnostics.get("findings") or []:
|
|
84
|
+
if isinstance(finding, dict) and str(finding.get("severity") or "").lower() == "error":
|
|
85
|
+
errors.append(str(finding.get("message") or "DESIGN.md linter error"))
|
|
86
|
+
elif proc.returncode != 0:
|
|
87
|
+
errors.append((proc.stderr or proc.stdout or "DESIGN.md linter failed").strip())
|
|
88
|
+
|
|
89
|
+
if errors:
|
|
90
|
+
require(False, "; ".join(errors))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def designmd_command() -> list[str]:
|
|
94
|
+
local_bin = repo_path("node_modules/.bin/designmd")
|
|
95
|
+
if local_bin.exists():
|
|
96
|
+
return [str(local_bin)]
|
|
97
|
+
return ["npx", "--no-install", "designmd"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
run_main(main)
|
package/dist/commands/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { doctor } from "./doctor.js";
|
|
2
|
+
import { inspectWorkflow } from "./inspect-workflow.js";
|
|
2
3
|
import { init } from "./init.js";
|
|
3
4
|
import { packageSource } from "./package-source.js";
|
|
4
5
|
import { sync } from "./sync.js";
|
|
@@ -10,11 +11,13 @@ export const commands = {
|
|
|
10
11
|
sync,
|
|
11
12
|
upgrade,
|
|
12
13
|
doctor,
|
|
14
|
+
"inspect-workflow": inspectWorkflow,
|
|
13
15
|
validate,
|
|
14
16
|
"validate-harness": (args) => validate(["validate-harness", ...args]),
|
|
15
17
|
"validate-current": (args) => validate(["validate-current", ...args]),
|
|
16
18
|
"validate-plan": (args) => validate(["validate-plan", ...args]),
|
|
17
19
|
"validate-pm": (args) => validate(["validate-pm", ...args]),
|
|
20
|
+
"validate-uiux": (args) => validate(["validate-uiux", ...args]),
|
|
18
21
|
"validate-design": (args) => validate(["validate-design", ...args]),
|
|
19
22
|
"validate-dev": (args) => validate(["validate-dev", ...args]),
|
|
20
23
|
"validate-review": (args) => validate(["validate-review", ...args]),
|
|
@@ -30,7 +33,8 @@ export function help() {
|
|
|
30
33
|
sync Materialize canonical assets into the workspace
|
|
31
34
|
upgrade Run migrations and then sync
|
|
32
35
|
doctor Diagnose project configuration and drift
|
|
36
|
+
inspect-workflow Lightly inspect workflow weight, fact-source drift, and handoff clarity
|
|
33
37
|
validate <gate> Run a Harness validation gate
|
|
34
|
-
validate-* Run a named gate directly, including validate-plan/review/test/release/rfc
|
|
38
|
+
validate-* Run a named gate directly, including validate-plan/uiux/design/dev/review/test/release/rfc
|
|
35
39
|
package <subcommand> Maintain package canonical source`);
|
|
36
40
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function inspectWorkflow(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { renderWorkflowInspection, renderWorkflowInspectionPrompt, runWorkflowInspection } from "../lib/workflow-inspector.js";
|
|
2
|
+
export async function inspectWorkflow(args) {
|
|
3
|
+
const options = {};
|
|
4
|
+
let json = false;
|
|
5
|
+
let prompt = false;
|
|
6
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
7
|
+
const arg = args[index];
|
|
8
|
+
if (arg === "--json") {
|
|
9
|
+
json = true;
|
|
10
|
+
}
|
|
11
|
+
else if (arg === "--prompt") {
|
|
12
|
+
prompt = true;
|
|
13
|
+
}
|
|
14
|
+
else if (arg === "--recent-minutes") {
|
|
15
|
+
options.recentMinutes = parsePositiveNumber(requireValue(args, ++index, arg), arg);
|
|
16
|
+
}
|
|
17
|
+
else if (arg === "--recent-turns") {
|
|
18
|
+
options.recentTurns = parsePositiveNumber(requireValue(args, ++index, arg), arg);
|
|
19
|
+
}
|
|
20
|
+
else if (arg === "--estimated-tokens") {
|
|
21
|
+
options.estimatedTokens = parsePositiveNumber(requireValue(args, ++index, arg), arg);
|
|
22
|
+
}
|
|
23
|
+
else if (arg === "--help" || arg === "-h") {
|
|
24
|
+
printHelp();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
throw new Error(`unknown inspect-workflow argument: ${arg}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const report = await runWorkflowInspection(process.cwd(), options);
|
|
32
|
+
if (json) {
|
|
33
|
+
console.log(JSON.stringify(report, null, 2));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
process.stdout.write(renderWorkflowInspection(report));
|
|
37
|
+
if (prompt) {
|
|
38
|
+
console.log("");
|
|
39
|
+
console.log(renderWorkflowInspectionPrompt(report));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (report.decision === "BLOCKED") {
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function printHelp() {
|
|
47
|
+
console.log(`sdlc-harness inspect-workflow
|
|
48
|
+
|
|
49
|
+
Lightly inspect whether a user repository is running the Harness workflow as intended.
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
--json Emit a machine-readable report
|
|
53
|
+
--prompt Print an Agent self-inspection prompt after the measured report
|
|
54
|
+
--recent-minutes <n> Self-reported recent workflow orientation time
|
|
55
|
+
--recent-turns <n> Self-reported recent workflow conversation turns
|
|
56
|
+
--estimated-tokens <n> Self-reported recent workflow token estimate`);
|
|
57
|
+
}
|
|
58
|
+
function requireValue(args, index, flag) {
|
|
59
|
+
const value = args[index];
|
|
60
|
+
if (!value || value.startsWith("--")) {
|
|
61
|
+
throw new Error(`${flag} requires a value`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
function parsePositiveNumber(value, flag) {
|
|
66
|
+
const parsed = Number(value);
|
|
67
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
68
|
+
throw new Error(`${flag} must be a non-negative number`);
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
package/dist/lib/harness-root.js
CHANGED
|
@@ -2,17 +2,17 @@ import path from "node:path";
|
|
|
2
2
|
import { DEFAULT_HARNESS_ROOT, HARNESS_JSON_CONFIG_PATH } from "./paths.js";
|
|
3
3
|
import { pathExists, readText } from "./fs.js";
|
|
4
4
|
export async function readHarnessRootConfig(projectRoot) {
|
|
5
|
-
const explicitConfig = await readJsonConfig(path.join(projectRoot, HARNESS_JSON_CONFIG_PATH));
|
|
6
|
-
const explicitValue = folderNameFromObject(explicitConfig);
|
|
7
|
-
if (explicitValue) {
|
|
8
|
-
return { harnessFolderName: normalizeHarnessFolderName(explicitValue), source: HARNESS_JSON_CONFIG_PATH };
|
|
9
|
-
}
|
|
10
5
|
const packageJson = await readJsonConfig(path.join(projectRoot, "package.json"));
|
|
11
6
|
const packageConfig = packageJson && typeof packageJson === "object" ? packageJson.sdlcHarness : undefined;
|
|
12
7
|
const packageValue = folderNameFromObject(packageConfig);
|
|
13
8
|
if (packageValue) {
|
|
14
9
|
return { harnessFolderName: normalizeHarnessFolderName(packageValue), source: "package.json#sdlcHarness" };
|
|
15
10
|
}
|
|
11
|
+
const explicitConfig = await readJsonConfig(path.join(projectRoot, HARNESS_JSON_CONFIG_PATH));
|
|
12
|
+
const explicitValue = folderNameFromObject(explicitConfig);
|
|
13
|
+
if (explicitValue) {
|
|
14
|
+
return { harnessFolderName: normalizeHarnessFolderName(explicitValue), source: HARNESS_JSON_CONFIG_PATH };
|
|
15
|
+
}
|
|
16
16
|
return { harnessFolderName: DEFAULT_HARNESS_ROOT, source: "default" };
|
|
17
17
|
}
|
|
18
18
|
export async function harnessRoot(projectRoot) {
|
package/dist/lib/init.js
CHANGED
|
@@ -7,6 +7,7 @@ import { syncDocsIndexMaintenanceSection, syncMemoryGuidanceSection } from "./us
|
|
|
7
7
|
const DOC_DIRS = [
|
|
8
8
|
".docs/00_raw",
|
|
9
9
|
".docs/01_product",
|
|
10
|
+
".docs/02_experience",
|
|
10
11
|
".docs/02_architecture",
|
|
11
12
|
".docs/03_tech_plan",
|
|
12
13
|
".docs/04_implementation",
|
|
@@ -31,7 +32,7 @@ export async function runInit(projectRoot, options) {
|
|
|
31
32
|
else {
|
|
32
33
|
report.push(`kept existing ${configPath}`);
|
|
33
34
|
}
|
|
34
|
-
await createProjectState(projectRoot, root, report);
|
|
35
|
+
await createProjectState(projectRoot, root, options.adopt, report);
|
|
35
36
|
await createDocs(projectRoot, report);
|
|
36
37
|
const syncReport = await runSync(projectRoot);
|
|
37
38
|
report.push(`sync changed=${syncReport.changed.length} skipped=${syncReport.skipped.length} blocked=${syncReport.blocked.length}`);
|
|
@@ -47,13 +48,16 @@ async function projectHasExistingFiles(projectRoot) {
|
|
|
47
48
|
}
|
|
48
49
|
return false;
|
|
49
50
|
}
|
|
50
|
-
async function createProjectState(projectRoot, root, report) {
|
|
51
|
+
async function createProjectState(projectRoot, root, adopt, report) {
|
|
51
52
|
const stateRoot = path.join(projectRoot, root, "state");
|
|
52
53
|
await ensureDir(stateRoot);
|
|
54
|
+
const lifecycle = adopt
|
|
55
|
+
? `project_name: "Project"\nversion: "v0.1"\ncurrent_phase: "SPRINTING"\nactive_role: "developer"\nactive_skill: "pjsdlc_dev_sprint"\ncurrent_milestone: "MVP"\nblocked_reason: ""\nsuspended_phase: ""\nallowed_next_phases:\n - "REVIEWING"\n - "RFC_RECALIBRATION"\n - "BLOCKED"\n`
|
|
56
|
+
: `project_name: "Project"\nversion: "v0.1"\ncurrent_phase: "REQUIREMENT_GATHERING"\nactive_role: "product_manager"\nactive_skill: "pjsdlc_pm_prd"\ncurrent_milestone: "MVP"\nblocked_reason: ""\nsuspended_phase: ""\nallowed_next_phases:\n - "UI_UX_DESIGNING"\n - "BLOCKED"\n`;
|
|
53
57
|
const files = [
|
|
54
58
|
[
|
|
55
59
|
harnessPath(root, "state", "lifecycle.yaml"),
|
|
56
|
-
|
|
60
|
+
lifecycle
|
|
57
61
|
],
|
|
58
62
|
[harnessPath(root, "state", "plan.yaml"), `current_task_id: ""\nnext_task_sequence: 1\ntasks: []\n`],
|
|
59
63
|
[harnessPath(root, "state", "plan.draft.yaml"), `next_task_sequence: 1\ntasks: []\n`],
|