cowork-flow 0.0.26 → 0.0.27
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 +4 -2
- package/package.json +1 -1
- package/template/.agents/skills/party-mode-v2/SKILL.md +73 -0
- package/template/.claude/agents/cowork-implement.md +163 -4
- package/template/.claude/skills/party-mode-v2/SKILL.md +73 -0
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/config.yaml +16 -0
- package/template/.cowork-flow/scripts/common/archive_utils.py +1 -1
- package/template/.cowork-flow/scripts/common/config.py +93 -0
- package/template/.cowork-flow/scripts/common/errors.py +107 -0
- package/template/.cowork-flow/scripts/common/validate_coding_standards.py +179 -0
- package/template/.cowork-flow/scripts/common/validate_implementation.py +195 -0
- package/template/.cowork-flow/scripts/common/validate_rules.py +302 -0
- package/template/.cowork-flow/scripts/party_mode_v2.py +646 -0
- package/template/.cowork-flow/scripts/run.py +3 -0
- package/template/.cowork-flow/scripts/task.py +66 -0
- package/template/.cowork-flow/spec/party-mode-v2-actions.schema.json +43 -0
- package/template/.cowork-flow/spec/party-mode-v2-board.md +43 -0
- package/template/.cowork-flow/spec/registry.json +13 -0
- package/template/.cowork-flow/spec/rules.json +159 -0
- package/template/.cowork-flow/spec/rules.schema.json +67 -0
- package/template/.cowork-flow/spec/subagent-dispatch.md +2 -0
- package/template/.cowork-flow/workflow.md +6 -0
- package/template/.opencode/agents/cowork-implement.md +153 -0
- package/template/.opencode/commands/party-mode-v2.md +21 -0
- package/template/AGENTS.md +2 -6
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Coding standards reminder script.
|
|
4
|
+
|
|
5
|
+
Reads coding standards from markdown files and outputs a summary.
|
|
6
|
+
Does NOT perform validation - only provides guidance for Agent review.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_coding_standards_summary(
|
|
17
|
+
repo_root: Path,
|
|
18
|
+
task_dir: Path,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Get coding standards summary based on changed files.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
repo_root: Repository root directory
|
|
25
|
+
task_dir: Task directory
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Summary string of relevant coding standards
|
|
29
|
+
"""
|
|
30
|
+
# Get modified files
|
|
31
|
+
modified_files = _get_modified_files(repo_root)
|
|
32
|
+
if not modified_files:
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
# Classify files
|
|
36
|
+
backend_files = [f for f in modified_files if _is_backend_file(f)]
|
|
37
|
+
frontend_files = [f for f in modified_files if _is_frontend_file(f)]
|
|
38
|
+
|
|
39
|
+
summaries = []
|
|
40
|
+
|
|
41
|
+
# Get backend standards summary
|
|
42
|
+
if backend_files:
|
|
43
|
+
backend_summary = _get_standards_summary(repo_root, "backend")
|
|
44
|
+
if backend_summary:
|
|
45
|
+
summaries.append(f"=== Backend Coding Standards ===\n{backend_summary}")
|
|
46
|
+
|
|
47
|
+
# Get frontend standards summary
|
|
48
|
+
if frontend_files:
|
|
49
|
+
frontend_summary = _get_standards_summary(repo_root, "frontend")
|
|
50
|
+
if frontend_summary:
|
|
51
|
+
summaries.append(f"=== Frontend Coding Standards ===\n{frontend_summary}")
|
|
52
|
+
|
|
53
|
+
return "\n\n".join(summaries)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_modified_files(repo_root: Path) -> list[str]:
|
|
57
|
+
"""Get list of modified files"""
|
|
58
|
+
try:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
["git", "diff", "--name-only"],
|
|
61
|
+
cwd=repo_root,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
)
|
|
65
|
+
if result.returncode == 0:
|
|
66
|
+
return [f for f in result.stdout.strip().split("\n") if f]
|
|
67
|
+
return []
|
|
68
|
+
except Exception:
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_backend_file(file_path: str) -> bool:
|
|
73
|
+
"""Check if file is a backend file"""
|
|
74
|
+
backend_patterns = [
|
|
75
|
+
r"\.py$",
|
|
76
|
+
r"\.java$",
|
|
77
|
+
r"\.go$",
|
|
78
|
+
r"\.rs$",
|
|
79
|
+
r"\.rb$",
|
|
80
|
+
]
|
|
81
|
+
return any(re.search(p, file_path) for p in backend_patterns)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_frontend_file(file_path: str) -> bool:
|
|
85
|
+
"""Check if file is a frontend file"""
|
|
86
|
+
frontend_patterns = [
|
|
87
|
+
r"\.tsx$",
|
|
88
|
+
r"\.jsx$",
|
|
89
|
+
r"\.vue$",
|
|
90
|
+
r"\.svelte$",
|
|
91
|
+
]
|
|
92
|
+
return any(re.search(p, file_path) for p in frontend_patterns)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _get_standards_summary(repo_root: Path, category: str) -> str:
|
|
96
|
+
"""
|
|
97
|
+
Get summary of coding standards from markdown files.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
repo_root: Repository root directory
|
|
101
|
+
category: 'backend' or 'frontend'
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Summary string
|
|
105
|
+
"""
|
|
106
|
+
spec_dir = repo_root / ".cowork-flow" / "spec" / category
|
|
107
|
+
if not spec_dir.exists():
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
summaries = []
|
|
111
|
+
|
|
112
|
+
for md_file in sorted(spec_dir.glob("*.md")):
|
|
113
|
+
if md_file.name == "index.md":
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
content = md_file.read_text(encoding="utf-8")
|
|
117
|
+
rules = _extract_key_rules(content)
|
|
118
|
+
|
|
119
|
+
if rules:
|
|
120
|
+
summaries.append(f"File: {md_file.name}")
|
|
121
|
+
for rule in rules[:5]: # Limit to top 5 rules per file
|
|
122
|
+
summaries.append(f" - {rule}")
|
|
123
|
+
summaries.append("")
|
|
124
|
+
|
|
125
|
+
return "\n".join(summaries)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _extract_key_rules(content: str) -> list[str]:
|
|
129
|
+
"""Extract key rules from markdown content"""
|
|
130
|
+
rules = []
|
|
131
|
+
|
|
132
|
+
# Extract forbidden patterns (Must not, Should not, Do not, etc.)
|
|
133
|
+
# Also match Chinese patterns
|
|
134
|
+
forbidden_patterns = [
|
|
135
|
+
r"[-*]\s*(Must not|Should not|Do not|Never|Avoid)\s+(.+)",
|
|
136
|
+
r"[-*]\s*(禁止|不得|不能|不应|避免|不要|不可)\s*(.+)",
|
|
137
|
+
r"[-*]\s*(.+不得|.+不能|.+不应|.+避免|.+不要)\s*(.+)",
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
for line in content.split("\n"):
|
|
141
|
+
line = line.strip()
|
|
142
|
+
if not line or line.startswith("#"):
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
for pattern in forbidden_patterns:
|
|
146
|
+
match = re.search(pattern, line, re.IGNORECASE)
|
|
147
|
+
if match:
|
|
148
|
+
# Extract the rule text
|
|
149
|
+
rule_text = line.lstrip("-* ").strip()
|
|
150
|
+
if len(rule_text) > 5: # Skip very short rules
|
|
151
|
+
rules.append(rule_text[:80]) # Limit length
|
|
152
|
+
break # Only match one pattern per line
|
|
153
|
+
|
|
154
|
+
return rules[:10] # Return top 10 rules
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main():
|
|
158
|
+
"""Main entry point"""
|
|
159
|
+
import argparse
|
|
160
|
+
|
|
161
|
+
parser = argparse.ArgumentParser(description="Get coding standards summary")
|
|
162
|
+
parser.add_argument("--task-dir", required=True, help="Task directory path")
|
|
163
|
+
parser.add_argument("--repo-root", default=".", help="Repository root path")
|
|
164
|
+
|
|
165
|
+
args = parser.parse_args()
|
|
166
|
+
|
|
167
|
+
repo_root = Path(args.repo_root).resolve()
|
|
168
|
+
task_dir = Path(args.task_dir).resolve()
|
|
169
|
+
|
|
170
|
+
summary = get_coding_standards_summary(repo_root, task_dir)
|
|
171
|
+
|
|
172
|
+
if summary:
|
|
173
|
+
print(summary)
|
|
174
|
+
else:
|
|
175
|
+
print("No relevant coding standards found for changed files.")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
main()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Implementation validation script.
|
|
4
|
+
|
|
5
|
+
Validates code changes against forbidden action rules after task implementation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_implementation(
|
|
18
|
+
repo_root: Path,
|
|
19
|
+
task_dir: Path,
|
|
20
|
+
) -> list[dict]:
|
|
21
|
+
"""
|
|
22
|
+
Validate code changes against implementation rules.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
repo_root: Repository root directory
|
|
26
|
+
task_dir: Task directory
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List of violations
|
|
30
|
+
"""
|
|
31
|
+
violations = []
|
|
32
|
+
|
|
33
|
+
# Get git diff
|
|
34
|
+
diff_output = _get_git_diff(repo_root)
|
|
35
|
+
if not diff_output:
|
|
36
|
+
return violations
|
|
37
|
+
|
|
38
|
+
# Check forbidden action rules
|
|
39
|
+
violations.extend(_check_spec_file_modifications(diff_output, repo_root))
|
|
40
|
+
violations.extend(_check_premature_abstraction(diff_output))
|
|
41
|
+
violations.extend(_check_unrequested_features(diff_output, task_dir))
|
|
42
|
+
|
|
43
|
+
return violations
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_git_diff(repo_root: Path) -> str:
|
|
47
|
+
"""Get git diff output"""
|
|
48
|
+
try:
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
["git", "diff", "--name-only"],
|
|
51
|
+
cwd=repo_root,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
)
|
|
55
|
+
if result.returncode != 0:
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
# Get actual diff content
|
|
59
|
+
files = result.stdout.strip().split("\n")
|
|
60
|
+
if not files or files == [""]:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
diff_result = subprocess.run(
|
|
64
|
+
["git", "diff"] + files,
|
|
65
|
+
cwd=repo_root,
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
)
|
|
69
|
+
return diff_result.stdout
|
|
70
|
+
except Exception:
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_spec_file_modifications(
|
|
75
|
+
diff_output: str,
|
|
76
|
+
repo_root: Path,
|
|
77
|
+
) -> list[dict]:
|
|
78
|
+
"""R-AG-002: Check if spec files were modified"""
|
|
79
|
+
violations = []
|
|
80
|
+
|
|
81
|
+
# Spec file path patterns
|
|
82
|
+
spec_patterns = [
|
|
83
|
+
r"\.cowork-flow/spec/",
|
|
84
|
+
r"\.cowork-flow/workflow\.md",
|
|
85
|
+
r"AGENTS\.md",
|
|
86
|
+
r"CLAUDE\.md",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
for pattern in spec_patterns:
|
|
90
|
+
if re.search(pattern, diff_output):
|
|
91
|
+
violations.append({
|
|
92
|
+
"rule_id": "R-AG-002",
|
|
93
|
+
"type": "forbidden_action",
|
|
94
|
+
"severity": "block",
|
|
95
|
+
"passed": False,
|
|
96
|
+
"message": "Subagent attempted to modify spec files",
|
|
97
|
+
"file": pattern,
|
|
98
|
+
"fix_hint": "Spec files can only be modified by main session",
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return violations
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _check_premature_abstraction(diff_output: str) -> list[dict]:
|
|
105
|
+
"""R-AG-006: Check if premature abstraction was used"""
|
|
106
|
+
violations = []
|
|
107
|
+
|
|
108
|
+
# Detection patterns for abstraction
|
|
109
|
+
abstraction_patterns = [
|
|
110
|
+
r"def\s+base_", # base_ prefixed functions
|
|
111
|
+
r"class\s+Abstract", # Abstract prefixed classes
|
|
112
|
+
r"class\s+Base", # Base prefixed classes
|
|
113
|
+
r"class\s+Generic", # Generic prefixed classes
|
|
114
|
+
r"def\s+create_factory", # Factory pattern
|
|
115
|
+
r"def\s+get_instance", # Singleton pattern
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
for pattern in abstraction_patterns:
|
|
119
|
+
matches = re.findall(pattern, diff_output)
|
|
120
|
+
if matches:
|
|
121
|
+
violations.append({
|
|
122
|
+
"rule_id": "R-AG-006",
|
|
123
|
+
"type": "forbidden_action",
|
|
124
|
+
"severity": "warn",
|
|
125
|
+
"passed": False,
|
|
126
|
+
"message": f"Premature abstraction detected: {pattern}",
|
|
127
|
+
"file": "",
|
|
128
|
+
"fix_hint": "Keep code simple and direct, only abstract when reuse is confirmed",
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return violations
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _check_unrequested_features(
|
|
135
|
+
diff_output: str,
|
|
136
|
+
task_dir: Path,
|
|
137
|
+
) -> list[dict]:
|
|
138
|
+
"""R-AG-005: Check if unrequested features were introduced"""
|
|
139
|
+
violations = []
|
|
140
|
+
|
|
141
|
+
# Read PRD
|
|
142
|
+
prd_path = task_dir / "prd.md"
|
|
143
|
+
if not prd_path.exists():
|
|
144
|
+
return violations
|
|
145
|
+
|
|
146
|
+
prd_content = prd_path.read_text(encoding="utf-8")
|
|
147
|
+
|
|
148
|
+
# Check for obvious extra features
|
|
149
|
+
# This is a simple heuristic check, actual implementation may need more complex NLP analysis
|
|
150
|
+
feature_indicators = [
|
|
151
|
+
r"Added.*feature",
|
|
152
|
+
r"New.*functionality",
|
|
153
|
+
r"Implemented.*module",
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
for pattern in feature_indicators:
|
|
157
|
+
if re.search(pattern, diff_output) and not re.search(pattern, prd_content):
|
|
158
|
+
violations.append({
|
|
159
|
+
"rule_id": "R-AG-005",
|
|
160
|
+
"type": "forbidden_action",
|
|
161
|
+
"severity": "block",
|
|
162
|
+
"passed": False,
|
|
163
|
+
"message": "Unrequested feature introduced",
|
|
164
|
+
"file": "",
|
|
165
|
+
"fix_hint": "Only implement features explicitly required by the PRD",
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
return violations
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main():
|
|
172
|
+
"""Main entry point"""
|
|
173
|
+
import argparse
|
|
174
|
+
|
|
175
|
+
parser = argparse.ArgumentParser(description="Validate implementation against spec")
|
|
176
|
+
parser.add_argument("--task-dir", required=True, help="Task directory path")
|
|
177
|
+
parser.add_argument("--repo-root", default=".", help="Repository root path")
|
|
178
|
+
|
|
179
|
+
args = parser.parse_args()
|
|
180
|
+
|
|
181
|
+
repo_root = Path(args.repo_root).resolve()
|
|
182
|
+
task_dir = Path(args.task_dir).resolve()
|
|
183
|
+
|
|
184
|
+
violations = validate_implementation(repo_root, task_dir)
|
|
185
|
+
|
|
186
|
+
if violations:
|
|
187
|
+
for v in violations:
|
|
188
|
+
print(json.dumps(v, ensure_ascii=False))
|
|
189
|
+
sys.exit(1)
|
|
190
|
+
|
|
191
|
+
sys.exit(0)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
main()
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Rule validation script for cowork-flow workflow.
|
|
4
|
+
|
|
5
|
+
Validates rules at task start/complete checkpoints.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_rules(
|
|
17
|
+
repo_root: Path,
|
|
18
|
+
scope: str,
|
|
19
|
+
task_dir: Path | None = None,
|
|
20
|
+
) -> list[dict]:
|
|
21
|
+
"""
|
|
22
|
+
Validate rules for given scope.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
repo_root: Repository root path
|
|
26
|
+
scope: Validation scope (task_start, task_complete, check, implement)
|
|
27
|
+
task_dir: Optional task directory path
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of violations (empty if all rules pass)
|
|
31
|
+
"""
|
|
32
|
+
violations = []
|
|
33
|
+
|
|
34
|
+
# Load rules
|
|
35
|
+
rules_path = repo_root / ".cowork-flow" / "spec" / "rules.json"
|
|
36
|
+
if not rules_path.exists():
|
|
37
|
+
return violations
|
|
38
|
+
|
|
39
|
+
with open(rules_path) as f:
|
|
40
|
+
rules_data = json.load(f)
|
|
41
|
+
|
|
42
|
+
rules = rules_data.get("rules", [])
|
|
43
|
+
|
|
44
|
+
# Filter rules by scope
|
|
45
|
+
applicable_rules = [
|
|
46
|
+
r for r in rules
|
|
47
|
+
if r["scope"] == scope or r["scope"] == "all"
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Validate each rule
|
|
51
|
+
for rule in applicable_rules:
|
|
52
|
+
violation = _validate_rule(rule, repo_root, task_dir)
|
|
53
|
+
if violation:
|
|
54
|
+
violations.append(violation)
|
|
55
|
+
|
|
56
|
+
return violations
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_rule(
|
|
60
|
+
rule: dict,
|
|
61
|
+
repo_root: Path,
|
|
62
|
+
task_dir: Path | None,
|
|
63
|
+
) -> dict | None:
|
|
64
|
+
"""Validate a single rule. Returns violation dict if rule fails."""
|
|
65
|
+
rule_type = rule["type"]
|
|
66
|
+
|
|
67
|
+
if rule_type == "phase_gate":
|
|
68
|
+
return _validate_phase_gate(rule, repo_root, task_dir)
|
|
69
|
+
elif rule_type == "forbidden_action":
|
|
70
|
+
# Forbidden actions are validated at runtime, not at checkpoints
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _validate_phase_gate(
|
|
77
|
+
rule: dict,
|
|
78
|
+
repo_root: Path,
|
|
79
|
+
task_dir: Path | None,
|
|
80
|
+
) -> dict | None:
|
|
81
|
+
"""Validate a phase_gate rule."""
|
|
82
|
+
rule_id = rule["id"]
|
|
83
|
+
|
|
84
|
+
# R-WF-001 to R-WF-005: L2 readiness checks
|
|
85
|
+
if rule_id in ["R-WF-001", "R-WF-002", "R-WF-003", "R-WF-004", "R-WF-005"]:
|
|
86
|
+
if task_dir is None:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
# Check if task has L2 change
|
|
90
|
+
change_dir = _find_linked_change(repo_root, task_dir)
|
|
91
|
+
if change_dir is None:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
# Validate specific file requirements
|
|
95
|
+
if rule_id == "R-WF-001":
|
|
96
|
+
return _check_file_exists(change_dir / "proposal.md", rule, "proposal.md")
|
|
97
|
+
elif rule_id == "R-WF-002":
|
|
98
|
+
return _check_file_exists(change_dir / "spec.md", rule, "spec.md")
|
|
99
|
+
elif rule_id == "R-WF-003":
|
|
100
|
+
return _check_file_exists(change_dir / "design.md", rule, "design.md")
|
|
101
|
+
elif rule_id == "R-WF-004":
|
|
102
|
+
return _check_plan_link(repo_root, change_dir, rule)
|
|
103
|
+
elif rule_id == "R-WF-005":
|
|
104
|
+
return _check_task_link(change_dir, rule)
|
|
105
|
+
|
|
106
|
+
# R-WF-006: Implementation without failing test
|
|
107
|
+
if rule_id == "R-WF-006":
|
|
108
|
+
# This is a warn-level rule, just log
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# R-WF-007: Task completion without check
|
|
112
|
+
if rule_id == "R-WF-007":
|
|
113
|
+
if scope == "task_complete" and task_dir:
|
|
114
|
+
task_json = task_dir / "task.json"
|
|
115
|
+
if task_json.exists():
|
|
116
|
+
with open(task_json) as f:
|
|
117
|
+
task_data = json.load(f)
|
|
118
|
+
if task_data.get("status") != "review":
|
|
119
|
+
return {
|
|
120
|
+
"rule_id": rule_id,
|
|
121
|
+
"type": rule["type"],
|
|
122
|
+
"severity": rule["severity"],
|
|
123
|
+
"passed": False,
|
|
124
|
+
"message": rule["message"],
|
|
125
|
+
"file": str(task_json),
|
|
126
|
+
"fix_hint": rule["fix_hint"],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _find_linked_change(repo_root: Path, task_dir: Path) -> Path | None:
|
|
133
|
+
"""Find the change directory linked to a task."""
|
|
134
|
+
changes_dir = repo_root / ".cowork-flow" / "changes"
|
|
135
|
+
if not changes_dir.exists():
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
task_name = task_dir.name
|
|
139
|
+
for change_dir in changes_dir.iterdir():
|
|
140
|
+
if change_dir.is_dir() and change_dir.name != "archive":
|
|
141
|
+
change_yaml = change_dir / "change.yaml"
|
|
142
|
+
if change_yaml.exists():
|
|
143
|
+
with open(change_yaml) as f:
|
|
144
|
+
content = f.read()
|
|
145
|
+
if task_name in content:
|
|
146
|
+
return change_dir
|
|
147
|
+
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _check_file_exists(
|
|
152
|
+
file_path: Path,
|
|
153
|
+
rule: dict,
|
|
154
|
+
filename: str,
|
|
155
|
+
) -> dict | None:
|
|
156
|
+
"""Check if a required file exists and is not empty."""
|
|
157
|
+
if not file_path.exists():
|
|
158
|
+
return {
|
|
159
|
+
"rule_id": rule["id"],
|
|
160
|
+
"type": rule["type"],
|
|
161
|
+
"severity": rule["severity"],
|
|
162
|
+
"passed": False,
|
|
163
|
+
"message": f"L2 readiness: {filename} is missing",
|
|
164
|
+
"file": str(file_path),
|
|
165
|
+
"fix_hint": rule["fix_hint"],
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if file_path.stat().st_size == 0:
|
|
169
|
+
return {
|
|
170
|
+
"rule_id": rule["id"],
|
|
171
|
+
"type": rule["type"],
|
|
172
|
+
"severity": rule["severity"],
|
|
173
|
+
"passed": False,
|
|
174
|
+
"message": f"L2 readiness: {filename} is empty",
|
|
175
|
+
"file": str(file_path),
|
|
176
|
+
"fix_hint": rule["fix_hint"],
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _check_plan_link(
|
|
183
|
+
repo_root: Path,
|
|
184
|
+
change_dir: Path,
|
|
185
|
+
rule: dict,
|
|
186
|
+
) -> dict | None:
|
|
187
|
+
"""Check if change has a plan link."""
|
|
188
|
+
change_yaml = change_dir / "change.yaml"
|
|
189
|
+
if not change_yaml.exists():
|
|
190
|
+
return {
|
|
191
|
+
"rule_id": rule["id"],
|
|
192
|
+
"type": rule["type"],
|
|
193
|
+
"severity": rule["severity"],
|
|
194
|
+
"passed": False,
|
|
195
|
+
"message": "L2 readiness: change.yaml is missing",
|
|
196
|
+
"file": str(change_yaml),
|
|
197
|
+
"fix_hint": rule["fix_hint"],
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
with open(change_yaml) as f:
|
|
201
|
+
content = f.read()
|
|
202
|
+
|
|
203
|
+
if "plan:" not in content or "plan: null" in content:
|
|
204
|
+
return {
|
|
205
|
+
"rule_id": rule["id"],
|
|
206
|
+
"type": rule["type"],
|
|
207
|
+
"severity": rule["severity"],
|
|
208
|
+
"passed": False,
|
|
209
|
+
"message": "L2 readiness: plan link is missing",
|
|
210
|
+
"file": str(change_yaml),
|
|
211
|
+
"fix_hint": rule["fix_hint"],
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _check_task_link(
|
|
218
|
+
change_dir: Path,
|
|
219
|
+
rule: dict,
|
|
220
|
+
) -> dict | None:
|
|
221
|
+
"""Check if change has a task link."""
|
|
222
|
+
change_yaml = change_dir / "change.yaml"
|
|
223
|
+
if not change_yaml.exists():
|
|
224
|
+
return {
|
|
225
|
+
"rule_id": rule["id"],
|
|
226
|
+
"type": rule["type"],
|
|
227
|
+
"severity": rule["severity"],
|
|
228
|
+
"passed": False,
|
|
229
|
+
"message": "L2 readiness: change.yaml is missing",
|
|
230
|
+
"file": str(change_yaml),
|
|
231
|
+
"fix_hint": rule["fix_hint"],
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
with open(change_yaml) as f:
|
|
235
|
+
content = f.read()
|
|
236
|
+
|
|
237
|
+
if "tasks:" not in content:
|
|
238
|
+
return {
|
|
239
|
+
"rule_id": rule["id"],
|
|
240
|
+
"type": rule["type"],
|
|
241
|
+
"severity": rule["severity"],
|
|
242
|
+
"passed": False,
|
|
243
|
+
"message": "L2 readiness: task link is missing",
|
|
244
|
+
"file": str(change_yaml),
|
|
245
|
+
"fix_hint": rule["fix_hint"],
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def log_violations(
|
|
252
|
+
violations: list[dict],
|
|
253
|
+
scope: str,
|
|
254
|
+
task_dir: Path | None,
|
|
255
|
+
repo_root: Path,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Log violations to rule-events.jsonl."""
|
|
258
|
+
if not violations:
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
log_dir = repo_root / ".cowork-flow" / "logs"
|
|
262
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
log_path = log_dir / "rule-events.jsonl"
|
|
264
|
+
|
|
265
|
+
log_entry = {
|
|
266
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
267
|
+
"scope": scope,
|
|
268
|
+
"task_dir": str(task_dir) if task_dir else None,
|
|
269
|
+
"violations": violations,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
with open(log_path, "a") as f:
|
|
273
|
+
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def main():
|
|
277
|
+
"""Main entry point for CLI usage."""
|
|
278
|
+
import argparse
|
|
279
|
+
|
|
280
|
+
parser = argparse.ArgumentParser(description="Validate workflow rules")
|
|
281
|
+
parser.add_argument("scope", choices=["task_start", "task_complete", "check", "implement"])
|
|
282
|
+
parser.add_argument("--task-dir", help="Task directory path")
|
|
283
|
+
parser.add_argument("--repo-root", default=".", help="Repository root path")
|
|
284
|
+
|
|
285
|
+
args = parser.parse_args()
|
|
286
|
+
|
|
287
|
+
repo_root = Path(args.repo_root).resolve()
|
|
288
|
+
task_dir = Path(args.task_dir).resolve() if args.task_dir else None
|
|
289
|
+
|
|
290
|
+
violations = validate_rules(repo_root, args.scope, task_dir)
|
|
291
|
+
|
|
292
|
+
if violations:
|
|
293
|
+
log_violations(violations, args.scope, task_dir, repo_root)
|
|
294
|
+
for v in violations:
|
|
295
|
+
print(json.dumps(v, ensure_ascii=False))
|
|
296
|
+
sys.exit(1)
|
|
297
|
+
|
|
298
|
+
sys.exit(0)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
main()
|