cowork-flow 0.0.26 → 0.0.28

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 (55) hide show
  1. package/README.md +16 -8
  2. package/package.json +1 -1
  3. package/src/lib/copy-template.js +2 -2
  4. package/template/.agents/skills/before-dev/SKILL.md +1 -0
  5. package/template/.agents/skills/check/SKILL.md +1 -0
  6. package/template/.agents/skills/party-mode-v2/SKILL.md +75 -0
  7. package/template/.agents/skills/tdd/SKILL.md +61 -0
  8. package/template/.agents/skills/writing-plans/SKILL.md +1 -0
  9. package/template/.claude/agents/cowork-implement.md +153 -0
  10. package/template/.claude/hooks/inject-workflow-state.py +17 -8
  11. package/template/.claude/skills/before-dev/SKILL.md +1 -0
  12. package/template/.claude/skills/check/SKILL.md +1 -0
  13. package/template/.claude/skills/party-mode-v2/SKILL.md +75 -0
  14. package/template/.claude/skills/tdd/SKILL.md +61 -0
  15. package/template/.claude/skills/writing-plans/SKILL.md +1 -0
  16. package/template/.codex/agents/cowork-check.toml +2 -0
  17. package/template/.codex/agents/cowork-implement.toml +2 -0
  18. package/template/.codex/hooks/inject-workflow-state.py +17 -8
  19. package/template/.cowork-flow/.version +1 -1
  20. package/template/.cowork-flow/config.yaml +16 -0
  21. package/template/.cowork-flow/scripts/common/archive_utils.py +1 -1
  22. package/template/.cowork-flow/scripts/common/coding_standards.py +209 -0
  23. package/template/.cowork-flow/scripts/common/config.py +93 -0
  24. package/template/.cowork-flow/scripts/common/gates.py +66 -0
  25. package/template/.cowork-flow/scripts/common/git_snapshot.py +83 -0
  26. package/template/.cowork-flow/scripts/common/state_machine.py +57 -0
  27. package/template/.cowork-flow/scripts/common/tdd_evidence.py +301 -0
  28. package/template/.cowork-flow/scripts/common/test_intent.py +265 -0
  29. package/template/.cowork-flow/scripts/common/validate_coding_standards.py +181 -0
  30. package/template/.cowork-flow/scripts/common/validate_implementation.py +243 -0
  31. package/template/.cowork-flow/scripts/common/validate_rules.py +465 -0
  32. package/template/.cowork-flow/scripts/doctor.py +18 -36
  33. package/template/.cowork-flow/scripts/party_mode_v2.py +1162 -0
  34. package/template/.cowork-flow/scripts/run.py +3 -0
  35. package/template/.cowork-flow/scripts/task.py +190 -0
  36. package/template/.cowork-flow/spec/contracts/index.md +12 -0
  37. package/template/.cowork-flow/spec/contracts/party-mode-v2-board.md +68 -0
  38. package/template/.cowork-flow/spec/{subagent-dispatch.md → contracts/subagent-dispatch.md} +2 -0
  39. package/template/.cowork-flow/spec/{workflow-state-templates.md → contracts/workflow-state-templates.md} +1 -1
  40. package/template/.cowork-flow/spec/index.md +15 -0
  41. package/template/.cowork-flow/spec/{registry.json → runtime/contract-registry.json} +17 -4
  42. package/template/.cowork-flow/spec/runtime/index.md +10 -0
  43. package/template/.cowork-flow/spec/runtime/rules.json +173 -0
  44. package/template/.cowork-flow/spec/schemas/index.md +10 -0
  45. package/template/.cowork-flow/spec/schemas/party-mode-v2-actions.schema.json +90 -0
  46. package/template/.cowork-flow/spec/schemas/rules.schema.json +80 -0
  47. package/template/.cowork-flow/workflow.md +13 -7
  48. package/template/.opencode/agents/cowork-implement.md +153 -0
  49. package/template/.opencode/commands/party-mode-v2.md +26 -0
  50. package/template/.opencode/plugins/cowork-flow.js +18 -7
  51. package/template/AGENTS.md +2 -6
  52. package/template/.cowork-flow/scripts/common/entry_classifier.py +0 -122
  53. /package/template/.cowork-flow/spec/{capabilities.md → contracts/capabilities.md} +0 -0
  54. /package/template/.cowork-flow/spec/{entry-contract.md → contracts/entry-contract.md} +0 -0
  55. /package/template/.cowork-flow/spec/{adapter.schema.json → schemas/adapter.schema.json} +0 -0
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env python3
2
+ """TDD evidence validation for task review gates."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+
10
+ EVIDENCE_FILE = "tdd.jsonl"
11
+
12
+ REQUIRED_EVIDENCE_FIELDS = (
13
+ "acceptanceId",
14
+ "testFile",
15
+ "testName",
16
+ "redCommand",
17
+ "redExitCode",
18
+ "redOutputExcerpt",
19
+ "failureReason",
20
+ "whyThisTestMatters",
21
+ "greenCommand",
22
+ "greenExitCode",
23
+ "broaderVerification",
24
+ )
25
+
26
+ REQUIRED_EXEMPTION_FIELDS = (
27
+ "acceptanceId",
28
+ "exemptionType",
29
+ "reason",
30
+ "verificationCommand",
31
+ )
32
+
33
+ TDD_REQUIRED_MARKERS = (
34
+ "behavior",
35
+ "bug",
36
+ "cli",
37
+ "runtime",
38
+ "gate",
39
+ "protocol",
40
+ "state",
41
+ "workflow",
42
+ "行为",
43
+ "行为变更",
44
+ "修复",
45
+ "状态",
46
+ "状态机",
47
+ "协议",
48
+ "门禁",
49
+ "阻断",
50
+ "数据格式",
51
+ "错误处理",
52
+ "实现",
53
+ "新增",
54
+ "修改",
55
+ )
56
+
57
+ NON_BEHAVIOR_MARKERS = (
58
+ "docs-only",
59
+ "documentation_only",
60
+ "documentation only",
61
+ "纯文档",
62
+ "仅文档",
63
+ "注释",
64
+ "格式化",
65
+ )
66
+
67
+ SETUP_FAILURE_MARKERS = (
68
+ "syntax",
69
+ "import",
70
+ "environment",
71
+ "setup",
72
+ "fixture",
73
+ "语法",
74
+ "导入",
75
+ "环境",
76
+ "测试设置",
77
+ )
78
+
79
+
80
+ def validate_tdd_evidence(task_dir: Path) -> list[dict]:
81
+ """Return TDD evidence violations for a task directory."""
82
+ task_dir = Path(task_dir)
83
+ prd_text = _read_text(task_dir / "prd.md")
84
+ evidence_path = task_dir / EVIDENCE_FILE
85
+
86
+ if not evidence_path.is_file():
87
+ if _task_requires_tdd(prd_text):
88
+ return [
89
+ _violation(
90
+ "TDD-RED-001",
91
+ "TDD evidence file is missing for a behavior-change task",
92
+ evidence_path,
93
+ "Create tdd.jsonl with red/green evidence or a documented exemption.",
94
+ )
95
+ ]
96
+ return []
97
+
98
+ entries, parse_violations = _read_jsonl(evidence_path)
99
+ if parse_violations:
100
+ return parse_violations
101
+
102
+ if not entries:
103
+ return [
104
+ _violation(
105
+ "TDD-RED-002",
106
+ "TDD evidence file is empty",
107
+ evidence_path,
108
+ "Add at least one evidence or exemption record.",
109
+ )
110
+ ]
111
+
112
+ acceptance_ids = _acceptance_ids(prd_text)
113
+ violations: list[dict] = []
114
+ for index, entry in enumerate(entries, start=1):
115
+ if entry.get("type") == "exemption":
116
+ violations.extend(_validate_exemption(entry, evidence_path, index, acceptance_ids))
117
+ else:
118
+ violations.extend(_validate_evidence(entry, evidence_path, index, acceptance_ids))
119
+
120
+ return violations
121
+
122
+
123
+ def _task_requires_tdd(prd_text: str) -> bool:
124
+ lower = prd_text.lower()
125
+ if not lower:
126
+ return False
127
+ if any(marker.lower() in lower for marker in NON_BEHAVIOR_MARKERS):
128
+ return False
129
+ return any(marker.lower() in lower for marker in TDD_REQUIRED_MARKERS)
130
+
131
+
132
+ def _read_text(path: Path) -> str:
133
+ try:
134
+ return path.read_text(encoding="utf-8")
135
+ except (OSError, UnicodeDecodeError):
136
+ return ""
137
+
138
+
139
+ def _read_jsonl(path: Path) -> tuple[list[dict], list[dict]]:
140
+ entries: list[dict] = []
141
+ violations: list[dict] = []
142
+ try:
143
+ lines = path.read_text(encoding="utf-8").splitlines()
144
+ except (OSError, UnicodeDecodeError):
145
+ return entries, [
146
+ _violation(
147
+ "TDD-READ-001",
148
+ "Failed to read TDD evidence as UTF-8",
149
+ path,
150
+ "Rewrite tdd.jsonl with UTF-8 encoding.",
151
+ )
152
+ ]
153
+
154
+ for line_number, line in enumerate(lines, start=1):
155
+ if not line.strip():
156
+ continue
157
+ try:
158
+ data = json.loads(line)
159
+ except json.JSONDecodeError:
160
+ violations.append(
161
+ _violation(
162
+ "TDD-FORMAT-001",
163
+ f"Invalid JSON in tdd.jsonl line {line_number}",
164
+ path,
165
+ "Write one valid JSON object per line.",
166
+ )
167
+ )
168
+ continue
169
+ if not isinstance(data, dict):
170
+ violations.append(
171
+ _violation(
172
+ "TDD-FORMAT-002",
173
+ f"tdd.jsonl line {line_number} must be a JSON object",
174
+ path,
175
+ "Replace non-object entries with structured evidence records.",
176
+ )
177
+ )
178
+ continue
179
+ entries.append(data)
180
+
181
+ return entries, violations
182
+
183
+
184
+ def _acceptance_ids(prd_text: str) -> set[str]:
185
+ return set(re.findall(r"\bAC-\d{3}\b", prd_text))
186
+
187
+
188
+ def _validate_evidence(
189
+ entry: dict,
190
+ evidence_path: Path,
191
+ index: int,
192
+ acceptance_ids: set[str],
193
+ ) -> list[dict]:
194
+ violations: list[dict] = []
195
+ missing = [field for field in REQUIRED_EVIDENCE_FIELDS if not _has_value(entry, field)]
196
+ if missing:
197
+ violations.append(
198
+ _violation(
199
+ "TDD-FIELD-001",
200
+ f"TDD evidence record {index} is missing required fields: {', '.join(missing)}",
201
+ evidence_path,
202
+ "Fill all red/green evidence fields before review.",
203
+ )
204
+ )
205
+
206
+ acceptance_id = str(entry.get("acceptanceId", "")).strip()
207
+ if acceptance_ids and acceptance_id not in acceptance_ids:
208
+ violations.append(
209
+ _violation(
210
+ "TDD-AC-001",
211
+ f"TDD evidence record {index} references unknown acceptanceId: {acceptance_id}",
212
+ evidence_path,
213
+ "Map evidence to a PRD acceptance ID such as AC-001.",
214
+ )
215
+ )
216
+
217
+ if entry.get("redExitCode") == 0:
218
+ violations.append(
219
+ _violation(
220
+ "TDD-RED-003",
221
+ f"TDD evidence record {index} redExitCode must be non-zero",
222
+ evidence_path,
223
+ "Capture the failing test result before implementation.",
224
+ )
225
+ )
226
+
227
+ if entry.get("greenExitCode") != 0:
228
+ violations.append(
229
+ _violation(
230
+ "TDD-GREEN-001",
231
+ f"TDD evidence record {index} greenExitCode must be 0",
232
+ evidence_path,
233
+ "Run the same behavior test after implementation and record success.",
234
+ )
235
+ )
236
+
237
+ failure_reason = str(entry.get("failureReason", "")).lower()
238
+ if any(marker in failure_reason for marker in SETUP_FAILURE_MARKERS):
239
+ violations.append(
240
+ _violation(
241
+ "TDD-RED-004",
242
+ f"TDD evidence record {index} red failure is not target behavior",
243
+ evidence_path,
244
+ "Red failure must come from the missing/incorrect target behavior, not setup noise.",
245
+ )
246
+ )
247
+
248
+ return violations
249
+
250
+
251
+ def _validate_exemption(
252
+ entry: dict,
253
+ evidence_path: Path,
254
+ index: int,
255
+ acceptance_ids: set[str],
256
+ ) -> list[dict]:
257
+ violations: list[dict] = []
258
+ missing = [field for field in REQUIRED_EXEMPTION_FIELDS if not _has_value(entry, field)]
259
+ if missing:
260
+ violations.append(
261
+ _violation(
262
+ "TDD-EXEMPT-001",
263
+ f"TDD exemption record {index} is missing required fields: {', '.join(missing)}",
264
+ evidence_path,
265
+ "Record exemptionType, reason, acceptanceId, and verificationCommand.",
266
+ )
267
+ )
268
+
269
+ acceptance_id = str(entry.get("acceptanceId", "")).strip()
270
+ if acceptance_ids and acceptance_id not in acceptance_ids:
271
+ violations.append(
272
+ _violation(
273
+ "TDD-EXEMPT-002",
274
+ f"TDD exemption record {index} references unknown acceptanceId: {acceptance_id}",
275
+ evidence_path,
276
+ "Map the exemption to a PRD acceptance ID such as AC-001.",
277
+ )
278
+ )
279
+
280
+ return violations
281
+
282
+
283
+ def _has_value(entry: dict, field: str) -> bool:
284
+ value = entry.get(field)
285
+ if value is None:
286
+ return False
287
+ if isinstance(value, str):
288
+ return bool(value.strip())
289
+ return True
290
+
291
+
292
+ def _violation(rule_id: str, message: str, file_path: Path, fix_hint: str) -> dict:
293
+ return {
294
+ "rule_id": rule_id,
295
+ "type": "tdd_evidence",
296
+ "severity": "block",
297
+ "passed": False,
298
+ "message": message,
299
+ "file": str(file_path),
300
+ "fix_hint": fix_hint,
301
+ }
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env python3
2
+ """Test intent validation for cowork-flow review gates."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ast
7
+ import io
8
+ import json
9
+ import tokenize
10
+ from pathlib import Path
11
+
12
+ from .tdd_evidence import EVIDENCE_FILE
13
+
14
+ REPORT_FIELD = "test_intent_review"
15
+
16
+ STRONG_ASSERT_MARKERS = (
17
+ "assertEqual",
18
+ "assertNotEqual",
19
+ "assertGreater",
20
+ "assertGreaterEqual",
21
+ "assertLess",
22
+ "assertLessEqual",
23
+ "assertRaises",
24
+ "assertRegex",
25
+ )
26
+
27
+ WEAK_ASSERT_MARKERS = (
28
+ "assertIsNotNone",
29
+ "assertIsInstance",
30
+ "assertIn(",
31
+ "assertNotIn(",
32
+ "assertTrue(",
33
+ "assertFalse(",
34
+ )
35
+
36
+ BLOCK_MARKERS = (
37
+ "assert True",
38
+ "assertTrue(True)",
39
+ "assertFalse(False)",
40
+ "hasattr(",
41
+ "getattr(",
42
+ "callable(",
43
+ "assert_called",
44
+ "call_count",
45
+ "assert_not_called",
46
+ )
47
+
48
+
49
+ def validate_test_intent(repo_root: Path, task_dir: Path) -> list[dict]:
50
+ """Return test intent warnings or blocks for the task's TDD evidence."""
51
+ evidence_path = Path(task_dir) / EVIDENCE_FILE
52
+ entries = _read_tdd_entries(evidence_path)
53
+ if not entries:
54
+ return []
55
+
56
+ violations: list[dict] = []
57
+ for index, entry in enumerate(entries, start=1):
58
+ if entry.get("type") == "exemption":
59
+ continue
60
+
61
+ test_file = entry.get("testFile")
62
+ test_name = str(entry.get("testName", "")).strip()
63
+ if not isinstance(test_file, str) or not test_file.strip():
64
+ violations.append(
65
+ _violation(
66
+ "TEST-INTENT-001",
67
+ f"TDD evidence record {index} is missing testFile",
68
+ evidence_path,
69
+ "Point the evidence to a concrete test file.",
70
+ severity="block",
71
+ )
72
+ )
73
+ continue
74
+
75
+ test_path = Path(repo_root) / test_file
76
+ content = _read_text(test_path)
77
+ if not content:
78
+ violations.append(
79
+ _violation(
80
+ "TEST-INTENT-002",
81
+ f"TDD evidence record {index} references missing test file: {test_file}",
82
+ evidence_path,
83
+ "Point the evidence at an actual test file that exercises behavior.",
84
+ severity="block",
85
+ )
86
+ )
87
+ continue
88
+
89
+ classification = _classify_test_content(content, test_name)
90
+ if classification == "missing":
91
+ violations.append(
92
+ _violation(
93
+ "TEST-INTENT-005",
94
+ f"TDD evidence record {index} testName does not resolve to a test function: {test_name}",
95
+ test_path,
96
+ "Use test_method, ClassName.test_method, or module.ClassName.test_method for the behavior test.",
97
+ severity="block",
98
+ )
99
+ )
100
+ elif classification == "block":
101
+ violations.append(
102
+ _violation(
103
+ "TEST-INTENT-003",
104
+ f"TDD evidence record {index} points to a shallow test such as assert True, import-only, mock-only, or function-existence checks",
105
+ test_path,
106
+ "Write a test that fails when the target behavior breaks.",
107
+ severity="block",
108
+ )
109
+ )
110
+ elif classification == "warn":
111
+ violations.append(
112
+ _violation(
113
+ "TEST-INTENT-004",
114
+ f"TDD evidence record {index} looks weak (assertIsNotNone/assertIsInstance/assertIn) and should be reviewed carefully",
115
+ test_path,
116
+ "Prefer tests that assert meaningful behavior or state transitions.",
117
+ severity="warn",
118
+ )
119
+ )
120
+
121
+ return violations
122
+
123
+
124
+ def _read_tdd_entries(path: Path) -> list[dict]:
125
+ if not path.is_file():
126
+ return []
127
+
128
+ entries: list[dict] = []
129
+ try:
130
+ lines = path.read_text(encoding="utf-8").splitlines()
131
+ except (OSError, UnicodeDecodeError):
132
+ return []
133
+
134
+ for line in lines:
135
+ if not line.strip():
136
+ continue
137
+ try:
138
+ data = json.loads(line)
139
+ except json.JSONDecodeError:
140
+ continue
141
+ if isinstance(data, dict):
142
+ entries.append(data)
143
+ return entries
144
+
145
+
146
+ def _read_text(path: Path) -> str:
147
+ try:
148
+ return path.read_text(encoding="utf-8")
149
+ except (OSError, UnicodeDecodeError):
150
+ return ""
151
+
152
+
153
+ def _classify_test_content(content: str, test_name: str) -> str:
154
+ target_content = _target_test_content(content, test_name)
155
+ if target_content is None:
156
+ return "missing"
157
+ scan_content = _strip_string_and_comment_literals(target_content)
158
+ lower = scan_content.lower()
159
+
160
+ if any(marker.lower() in lower for marker in BLOCK_MARKERS):
161
+ return "block"
162
+
163
+ if _looks_mock_only(lower):
164
+ return "block"
165
+
166
+ if _looks_import_only(content, lower):
167
+ return "block"
168
+
169
+ if _looks_ambiguous(lower):
170
+ return "warn"
171
+
172
+ return "pass"
173
+
174
+
175
+ def _strip_string_and_comment_literals(content: str) -> str:
176
+ try:
177
+ tokens = []
178
+ for token in tokenize.generate_tokens(io.StringIO(content).readline):
179
+ if token.type in (tokenize.STRING, tokenize.COMMENT):
180
+ tokens.append(token._replace(string='""'))
181
+ else:
182
+ tokens.append(token)
183
+ return tokenize.untokenize(tokens)
184
+ except (IndentationError, tokenize.TokenError):
185
+ return content
186
+
187
+
188
+ def _target_test_content(content: str, test_name: str) -> str | None:
189
+ parse_content = content.lstrip("\ufeff")
190
+ try:
191
+ tree = ast.parse(parse_content)
192
+ except SyntaxError:
193
+ return parse_content if test_name and test_name in parse_content else None
194
+
195
+ target_class, target_function = _parse_test_name(test_name)
196
+ if not target_function:
197
+ return None
198
+
199
+ for node in ast.walk(tree):
200
+ if target_class and isinstance(node, ast.ClassDef) and node.name == target_class:
201
+ for child in node.body:
202
+ if isinstance(child, ast.FunctionDef) and child.name == target_function:
203
+ return ast.get_source_segment(parse_content, child) or parse_content
204
+ return None
205
+ if not target_class and isinstance(node, ast.FunctionDef) and node.name == target_function:
206
+ return ast.get_source_segment(parse_content, node) or parse_content
207
+ return None
208
+
209
+
210
+ def _parse_test_name(test_name: str) -> tuple[str | None, str | None]:
211
+ normalized = test_name.strip()
212
+ if not normalized:
213
+ return None, None
214
+ parts = [part for part in normalized.split(".") if part]
215
+ if not parts:
216
+ return None, None
217
+ target_function = parts[-1]
218
+ target_class = parts[-2] if len(parts) >= 2 and parts[-2][:1].isupper() else None
219
+ return target_class, target_function
220
+
221
+
222
+ def _looks_import_only(content: str, lower: str) -> bool:
223
+ if "assert" in lower:
224
+ return False
225
+ if "expect(" in lower or ".to" in lower:
226
+ return False
227
+ return "def test_" in lower or "class " in lower
228
+
229
+
230
+ def _looks_mock_only(lower: str) -> bool:
231
+ mock_markers = ("assert_called", "call_count", "assert_not_called", "mock(", "Mock(")
232
+ return any(marker in lower for marker in mock_markers) and not any(
233
+ marker in lower for marker in ("assertequal(", "assertraises(", "assertin(", "asserttrue(")
234
+ )
235
+
236
+
237
+ def _looks_ambiguous(lower: str) -> bool:
238
+ weak_only_markers = (
239
+ "assertisnotnone(",
240
+ "assertisinstance(",
241
+ "assertin(",
242
+ "assertnotin(",
243
+ )
244
+ if any(marker in lower for marker in weak_only_markers):
245
+ strong_markers = (
246
+ "assertequal(",
247
+ "assertnotequal(",
248
+ "assertgreater(",
249
+ "assertraises(",
250
+ "assertregex(",
251
+ )
252
+ return not any(marker in lower for marker in strong_markers)
253
+ return False
254
+
255
+
256
+ def _violation(rule_id: str, message: str, file_path: Path, fix_hint: str, *, severity: str) -> dict:
257
+ return {
258
+ "rule_id": rule_id,
259
+ "type": "test_intent",
260
+ "severity": severity,
261
+ "passed": False,
262
+ "message": message,
263
+ "file": str(file_path),
264
+ "fix_hint": fix_hint,
265
+ }