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.
Files changed (42) hide show
  1. package/README.md +35 -10
  2. package/assets/agents/AGENTS_CORE.md +14 -9
  3. package/assets/docs/README.md +64 -11
  4. package/assets/make/sdlc-harness.mk +5 -1
  5. package/assets/policies/allowed_paths.yaml +9 -0
  6. package/assets/policies/gates.yaml +6 -0
  7. package/assets/policies/phase_contracts.yaml +49 -0
  8. package/assets/skills/pjsdlc_architect_design/SKILL.md +14 -8
  9. package/assets/skills/pjsdlc_dev_sprint/SKILL.md +8 -3
  10. package/assets/skills/pjsdlc_implementation_doc/SKILL.md +9 -4
  11. package/assets/skills/pjsdlc_manager/SKILL.md +17 -16
  12. package/assets/skills/pjsdlc_reviewer/SKILL.md +6 -1
  13. package/assets/skills/pjsdlc_rfc_recalibrate/SKILL.md +8 -5
  14. package/assets/skills/pjsdlc_tester/SKILL.md +12 -4
  15. package/assets/skills/pjsdlc_uiux_design/SKILL.md +76 -0
  16. package/assets/templates/PLAN_TEMPLATE.yaml +4 -0
  17. package/assets/templates/REVIEW_TEMPLATE.md +14 -4
  18. package/assets/templates/RFC_TEMPLATE.md +13 -5
  19. package/assets/templates/TECH_DESIGN_TEMPLATE.md +18 -10
  20. package/assets/templates/TEST_CASES_TEMPLATE.md +5 -3
  21. package/assets/templates/TEST_STRATEGY_TEMPLATE.md +4 -0
  22. package/assets/templates/UI_UX_DESIGN_TEMPLATE.md +67 -0
  23. package/assets/tools/harness_utils.py +92 -18
  24. package/assets/tools/transition.py +2 -1
  25. package/assets/tools/validate_allowed_paths.py +2 -2
  26. package/assets/tools/validate_design.py +56 -3
  27. package/assets/tools/validate_dev_state.py +1 -1
  28. package/assets/tools/validate_harness.py +17 -14
  29. package/assets/tools/validate_plan_draft.py +1 -1
  30. package/assets/tools/validate_prompt_language.py +17 -17
  31. package/assets/tools/validate_rfc.py +31 -0
  32. package/assets/tools/validate_test_plan.py +118 -1
  33. package/assets/tools/validate_uiux_design.py +101 -0
  34. package/dist/commands/index.js +5 -1
  35. package/dist/commands/inspect-workflow.d.ts +1 -0
  36. package/dist/commands/inspect-workflow.js +71 -0
  37. package/dist/lib/harness-root.js +5 -5
  38. package/dist/lib/init.js +7 -3
  39. package/dist/lib/validators.js +341 -27
  40. package/dist/lib/workflow-inspector.d.ts +35 -0
  41. package/dist/lib/workflow-inspector.js +340 -0
  42. 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
- ".codex/state/lifecycle.yaml",
21
- ".codex/state/plan.yaml",
22
- ".codex/state/plan.draft.yaml",
23
- ".codex/state/memory.md",
24
- ".codex/pjsdlc_managed/templates/PLAN_TEMPLATE.yaml",
25
- ".codex/pjsdlc_managed/policies/phase_contracts.yaml",
26
- ".codex/pjsdlc_managed/policies/gates.yaml",
27
- ".codex/pjsdlc_managed/policies/allowed_paths.yaml",
28
- ".codex/pjsdlc_managed/policies/risk_matrix.yaml",
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
- ".codex/skills",
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(".codex/pjsdlc_managed/policies/gates.yaml")
53
- load_yaml(".codex/pjsdlc_managed/policies/allowed_paths.yaml")
54
- load_yaml(".codex/pjsdlc_managed/policies/risk_matrix.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".codex/skills/{skill}/SKILL.md")
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(".codex/state/plan.draft.yaml")
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
- for term in REQUIRED_AGENTS_TERMS:
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
- skill_files = sorted((ROOT / ".codex/skills").glob("*/SKILL.md"))
89
- require(skill_files, "No workflow skill files found under .codex/skills/")
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 / ".codex/pjsdlc_managed/templates/SKILL_TEMPLATE.md"
105
- require(path.exists(), "Missing .codex/pjsdlc_managed/templates/SKILL_TEMPLATE.md")
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(".codex/state/lifecycle.yaml")
115
- tasks = load_yaml(".codex/state/plan.yaml")
116
- phase_contracts = load_yaml(".codex/pjsdlc_managed/policies/phase_contracts.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
- validate_plan_contract(load_plan(), allow_open=False)
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)
@@ -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
+ }
@@ -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
- `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`
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`],