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
@@ -42,6 +42,7 @@ Start with:
42
42
  - Include a failing test before implementation when behavior can be tested.
43
43
  - Do not add shallow tests just to satisfy process. Avoid tests that only assert existence, mirror implementation details, count mocks without behavior, or snapshot empty structure.
44
44
  - For complex problems, test depth first: cover invariants, cross-layer contracts, state transitions, error boundaries, and real regression paths before narrow unit cases.
45
+ - Map each behavior-changing test to a stable PRD acceptance ID and note where `tdd.jsonl` will record the red-green evidence.
45
46
  - Avoid placeholders such as TODO, TBD, "handle edge cases", or "write tests".
46
47
  - Keep root/template parity explicit when both copies exist.
47
48
 
@@ -29,12 +29,14 @@ Load context before checking:
29
29
  2. Read `<task>/prd.md`.
30
30
  3. Read `<task>/check.jsonl`.
31
31
  4. Read `git diff`.
32
+ 5. Include `test_intent_review` in the review output for meaningful behavior breaks coverage and shallow tests rejection.
32
33
 
33
34
  Rules:
34
35
  - Fix issues directly when they are clearly in scope.
35
36
  - MUST NOT spawn, wait for, list, or close other agents.
36
37
  - MUST NOT commit, archive, or mutate cowork-flow task state.
37
38
  - Report findings, changed files, and exact verification commands.
39
+ - Include test intent findings that map tests back to PRD acceptance or regression behavior.
38
40
  """
39
41
 
40
42
  [features]
@@ -30,12 +30,14 @@ Load context before editing:
30
30
  3. Read `<task>/info.md` if present.
31
31
  4. Read `<task>/implement.jsonl`.
32
32
  5. Read each JSONL `file` entry.
33
+ 6. For behavior-change tasks, create or verify `<task>/tdd.jsonl` before implementation or record a documented exemption.
33
34
 
34
35
  Rules:
35
36
  - MUST NOT spawn, wait for, list, or close other agents.
36
37
  - MUST NOT run task start, task finish, task archive, or unscoped resume.
37
38
  - MUST NOT commit or push.
38
39
  - Keep edits inside requested scope.
40
+ - Do not treat shallow tests as TDD evidence; red-green-refactor proof must include redCommand, redExitCode, greenCommand, greenExitCode, and PRD acceptance ID mapping.
39
41
  - Report changed files and exact verification commands.
40
42
  """
41
43
 
@@ -20,7 +20,7 @@ DEFAULT_CONTRACT_REGISTRY = {
20
20
  "contracts": [
21
21
  {
22
22
  "id": "COWORK_ENTRY_CONTRACT_V1",
23
- "path": ".cowork-flow/spec/entry-contract.md",
23
+ "path": ".cowork-flow/spec/contracts/entry-contract.md",
24
24
  "digest": [
25
25
  "Classify main-session requests before task start, resume, archive, or commit.",
26
26
  "Runtime context, not prompt labels, identifies formal subagent sessions.",
@@ -29,7 +29,7 @@ DEFAULT_CONTRACT_REGISTRY = {
29
29
  },
30
30
  {
31
31
  "id": "RUNTIME_CONTEXT_DISPATCH_V2",
32
- "path": ".cowork-flow/spec/subagent-dispatch.md",
32
+ "path": ".cowork-flow/spec/contracts/subagent-dispatch.md",
33
33
  "digest": [
34
34
  "Formal subagent work is keyed by cowork_runtime_context_id.",
35
35
  "Explicit shim bind records bound_context_key before formal output is accepted.",
@@ -72,7 +72,7 @@ def _read_hook_input() -> dict[str, Any]:
72
72
 
73
73
 
74
74
  def _load_breadcrumbs(root: Path) -> dict[str, str]:
75
- workflow = root / ".cowork-flow" / "spec" / "workflow-state-templates.md"
75
+ workflow = root / ".cowork-flow" / "spec" / "contracts" / "workflow-state-templates.md"
76
76
  try:
77
77
  text = workflow.read_text(encoding="utf-8")
78
78
  except OSError:
@@ -86,16 +86,23 @@ def _as_string_list(value: Any) -> list[str]:
86
86
  return [item for item in value if isinstance(item, str) and item.strip()]
87
87
 
88
88
 
89
- def _load_contract_registry(root: Path) -> list[dict[str, Any]]:
90
- registry_file = root / ".cowork-flow" / "spec" / "registry.json"
89
+ def _load_contract_registry(root: Path) -> tuple[list[dict[str, Any]], str | None]:
90
+ registry_file = root / ".cowork-flow" / "spec" / "runtime" / "contract-registry.json"
91
91
  try:
92
92
  data = json.loads(registry_file.read_text(encoding="utf-8"))
93
- except (OSError, json.JSONDecodeError):
93
+ except OSError:
94
94
  data = DEFAULT_CONTRACT_REGISTRY
95
+ warning = f"contract registry unavailable at {registry_file}; using fallback digest"
96
+ except json.JSONDecodeError:
97
+ data = DEFAULT_CONTRACT_REGISTRY
98
+ warning = f"contract registry invalid at {registry_file}; using fallback digest"
99
+ else:
100
+ warning = None
95
101
  contracts = data.get("contracts") if isinstance(data, dict) else None
96
102
  if not isinstance(contracts, list):
97
103
  contracts = DEFAULT_CONTRACT_REGISTRY["contracts"]
98
- return [contract for contract in contracts if isinstance(contract, dict)]
104
+ warning = warning or f"contract registry has no contracts array at {registry_file}; using fallback digest"
105
+ return [contract for contract in contracts if isinstance(contract, dict)], warning
99
106
 
100
107
 
101
108
  def _contract_fingerprint(root: Path, contracts: list[dict[str, Any]]) -> str:
@@ -113,13 +120,15 @@ def _contract_fingerprint(root: Path, contracts: list[dict[str, Any]]) -> str:
113
120
 
114
121
 
115
122
  def _build_contract_digest(root: Path) -> str:
116
- contracts = _load_contract_registry(root)
123
+ contracts, warning = _load_contract_registry(root)
117
124
  fingerprint = _contract_fingerprint(root, contracts)
118
125
  lines = [
119
126
  '<cowork-runtime host="codex" adapter="codex.spawn_agent">',
120
127
  f'<contract-digest fingerprint="{fingerprint}">',
121
128
  "policy: repeat this short digest every hook; read full spec files only before listed actions.",
122
129
  ]
130
+ if warning:
131
+ lines.append(f"warning: {warning}")
123
132
  for contract in contracts:
124
133
  contract_id = contract.get("id")
125
134
  path = contract.get("path")
@@ -1 +1 @@
1
- 0.0.26
1
+ 0.0.28
@@ -28,6 +28,22 @@ codex:
28
28
  # .cowork-flow/.runtime/subagents/<id>.json with the subagent helper, then
29
29
  # passes cowork_runtime_context_id through the active host adapter transport.
30
30
 
31
+ #-------------------------------------------------------------------------------
32
+ # Party Mode V2 Runtime Board
33
+ #-------------------------------------------------------------------------------
34
+
35
+ # Party Mode V2 is an advisory, runtime-controlled board discussion mode.
36
+ # The Python runtime owns board state and emits host-neutral next actions; the
37
+ # active host adapter still performs dispatch/wait/close.
38
+ party_mode_v2:
39
+ min_agents: 3
40
+ max_agents: 5
41
+ max_rounds: 5
42
+ max_rebuttal_targets_per_agent: 2
43
+ max_drift_warnings: 2
44
+ fresh_context_per_round: "true"
45
+ require_current_round_only: "true"
46
+
31
47
  #-------------------------------------------------------------------------------
32
48
  # Task Lifecycle Hooks
33
49
  #-------------------------------------------------------------------------------
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python3
1
+ #!/usr/bin/env python3
2
2
  """Small resumable directory archive helpers."""
3
3
 
4
4
  from __future__ import annotations
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env python3
2
+ """Coding standards validators for changed files."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ast
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Iterable, Sequence
10
+
11
+ from .git_snapshot import ChangedFile
12
+
13
+ TEXT_IO_SUFFIXES = {".py", ".ps1", ".js", ".mjs", ".cjs", ".ts", ".tsx"}
14
+ EXEMPT_PATH_PREFIXES = (
15
+ "tests/fixtures/coding-standards/",
16
+ "test/fixtures/coding-standards/",
17
+ )
18
+
19
+ NODE_TEXT_IO_PATTERN = re.compile(
20
+ r"\b(readFile|readFileSync|writeFile|writeFileSync)\s*\(",
21
+ )
22
+ POWERSHELL_TEXT_IO_PATTERN = re.compile(
23
+ r"\b(Get-Content|Set-Content|Out-File)\b",
24
+ re.IGNORECASE,
25
+ )
26
+
27
+
28
+ def validate_changed_files(
29
+ repo_root: Path,
30
+ changed_files: Sequence[ChangedFile],
31
+ ) -> list[dict]:
32
+ """Validate coding standards for changed files."""
33
+ violations: list[dict] = []
34
+ for changed_file in changed_files:
35
+ rel_path = changed_file.path
36
+ if _is_exempt_path(rel_path):
37
+ continue
38
+
39
+ file_path = Path(repo_root) / rel_path
40
+ if not file_path.is_file() or file_path.suffix.lower() not in TEXT_IO_SUFFIXES:
41
+ continue
42
+
43
+ try:
44
+ content = file_path.read_text(encoding="utf-8")
45
+ except UnicodeDecodeError:
46
+ violations.append(
47
+ _violation(
48
+ "CS-UTF8-READ-001",
49
+ rel_path,
50
+ "Coding standards: changed text file must be valid UTF-8",
51
+ "Rewrite the file as UTF-8 without relying on the system default encoding.",
52
+ )
53
+ )
54
+ continue
55
+ except OSError:
56
+ continue
57
+
58
+ suffix = file_path.suffix.lower()
59
+ if suffix == ".py":
60
+ violations.extend(_validate_python_text_io(rel_path, content))
61
+ elif suffix == ".ps1":
62
+ violations.extend(_validate_powershell_text_io(rel_path, content))
63
+ elif suffix in {".js", ".mjs", ".cjs", ".ts", ".tsx"}:
64
+ violations.extend(_validate_node_text_io(rel_path, content))
65
+
66
+ return violations
67
+
68
+
69
+ def _validate_python_text_io(rel_path: str, content: str) -> list[dict]:
70
+ try:
71
+ tree = ast.parse(content)
72
+ except SyntaxError:
73
+ return []
74
+
75
+ violations: list[dict] = []
76
+ for node in ast.walk(tree):
77
+ if not isinstance(node, ast.Call):
78
+ continue
79
+
80
+ function_name = _call_name(node)
81
+ if function_name in {"open", "read_text", "write_text"}:
82
+ if _is_binary_python_call(node, function_name):
83
+ continue
84
+ if not _has_utf8_encoding(node):
85
+ violations.append(
86
+ _violation(
87
+ "CS-UTF8-PY-001",
88
+ rel_path,
89
+ "Coding standards: Python text IO must pass encoding=\"utf-8\"",
90
+ "Add encoding=\"utf-8\" to open(), Path.open(), read_text(), or write_text().",
91
+ line=getattr(node, "lineno", None),
92
+ )
93
+ )
94
+
95
+ return violations
96
+
97
+
98
+ def _validate_node_text_io(rel_path: str, content: str) -> list[dict]:
99
+ violations: list[dict] = []
100
+ lines = content.splitlines()
101
+ for index, line in enumerate(lines):
102
+ if not NODE_TEXT_IO_PATTERN.search(line):
103
+ continue
104
+
105
+ segment = "\n".join(lines[index : min(index + 5, len(lines))]).lower()
106
+ if "utf8" in segment or "utf-8" in segment:
107
+ continue
108
+
109
+ violations.append(
110
+ _violation(
111
+ "CS-UTF8-NODE-001",
112
+ rel_path,
113
+ "Coding standards: Node file IO must specify utf8 encoding",
114
+ "Pass 'utf8' or { encoding: 'utf8' } to readFile/writeFile calls.",
115
+ line=index + 1,
116
+ )
117
+ )
118
+
119
+ return violations
120
+
121
+
122
+ def _validate_powershell_text_io(rel_path: str, content: str) -> list[dict]:
123
+ violations: list[dict] = []
124
+ for line_number, raw_line in enumerate(content.splitlines(), start=1):
125
+ line = raw_line.strip()
126
+ if not line or line.startswith("#"):
127
+ continue
128
+ if POWERSHELL_TEXT_IO_PATTERN.search(line) and "-encoding" not in line.lower():
129
+ violations.append(
130
+ _violation(
131
+ "CS-UTF8-PS-001",
132
+ rel_path,
133
+ "Coding standards: PowerShell text IO must specify -Encoding UTF8",
134
+ "Add -Encoding UTF8 to Get-Content, Set-Content, or Out-File.",
135
+ line=line_number,
136
+ )
137
+ )
138
+
139
+ return violations
140
+
141
+
142
+ def _call_name(node: ast.Call) -> str:
143
+ function = node.func
144
+ if isinstance(function, ast.Name):
145
+ return function.id
146
+ if isinstance(function, ast.Attribute):
147
+ return function.attr
148
+ return ""
149
+
150
+
151
+ def _is_binary_python_call(node: ast.Call, function_name: str) -> bool:
152
+ mode_values: list[str] = []
153
+ for keyword in node.keywords:
154
+ if keyword.arg == "mode":
155
+ mode_values.extend(_literal_strings([keyword.value]))
156
+
157
+ positional_mode_index = 1 if function_name == "open" else 0
158
+ if len(node.args) > positional_mode_index:
159
+ mode_values.extend(_literal_strings([node.args[positional_mode_index]]))
160
+
161
+ return any("b" in value for value in mode_values)
162
+
163
+
164
+ def _has_utf8_encoding(node: ast.Call) -> bool:
165
+ for keyword in node.keywords:
166
+ if keyword.arg != "encoding":
167
+ continue
168
+ values = _literal_strings([keyword.value])
169
+ return bool(values) and all(_is_utf8(value) for value in values)
170
+ return False
171
+
172
+
173
+ def _literal_strings(nodes: Iterable[ast.AST]) -> list[str]:
174
+ values: list[str] = []
175
+ for node in nodes:
176
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
177
+ values.append(node.value.lower())
178
+ return values
179
+
180
+
181
+ def _is_utf8(value: str) -> bool:
182
+ return value.replace("_", "-").lower() in {"utf-8", "utf8"}
183
+
184
+
185
+ def _is_exempt_path(rel_path: str) -> bool:
186
+ normalized = rel_path.replace("\\", "/")
187
+ return any(normalized.startswith(prefix) for prefix in EXEMPT_PATH_PREFIXES)
188
+
189
+
190
+ def _violation(
191
+ rule_id: str,
192
+ file_path: str,
193
+ message: str,
194
+ fix_hint: str,
195
+ *,
196
+ line: int | None = None,
197
+ ) -> dict:
198
+ violation = {
199
+ "rule_id": rule_id,
200
+ "type": "coding_standard",
201
+ "severity": "block",
202
+ "passed": False,
203
+ "message": message,
204
+ "file": file_path,
205
+ "fix_hint": fix_hint,
206
+ }
207
+ if line is not None:
208
+ violation["line"] = line
209
+ return violation
@@ -16,6 +16,13 @@ from .paths import DIR_WORKFLOW, get_repo_root
16
16
  DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
17
17
  DEFAULT_MAX_JOURNAL_LINES = 2000
18
18
  DEFAULT_CODEX_DISPATCH_MODE = "sub-agent"
19
+ DEFAULT_PARTY_MODE_V2_MIN_AGENTS = 3
20
+ DEFAULT_PARTY_MODE_V2_MAX_AGENTS = 5
21
+ DEFAULT_PARTY_MODE_V2_MAX_ROUNDS = 5
22
+ DEFAULT_PARTY_MODE_V2_MAX_REBUTTAL_TARGETS_PER_AGENT = 2
23
+ DEFAULT_PARTY_MODE_V2_MAX_DRIFT_WARNINGS = 2
24
+ DEFAULT_PARTY_MODE_V2_FRESH_CONTEXT_PER_ROUND = True
25
+ DEFAULT_PARTY_MODE_V2_REQUIRE_CURRENT_ROUND_ONLY = True
19
26
 
20
27
  CONFIG_FILE = "config.yaml"
21
28
 
@@ -139,3 +146,89 @@ def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
139
146
  if mode in {"sub-agent", "inline"}:
140
147
  return str(mode)
141
148
  return DEFAULT_CODEX_DISPATCH_MODE
149
+
150
+ def _get_section(config: dict, section_name: str) -> dict:
151
+ section = config.get(section_name)
152
+ if isinstance(section, dict):
153
+ return section
154
+ return {}
155
+
156
+ def _get_int(section: dict, key: str, default: int, *, minimum: int | None = None) -> int:
157
+ value = section.get(key, default)
158
+ try:
159
+ parsed = int(value)
160
+ except (TypeError, ValueError):
161
+ return default
162
+ if minimum is not None and parsed < minimum:
163
+ return default
164
+ return parsed
165
+
166
+ def _get_bool(section: dict, key: str, default: bool) -> bool:
167
+ value = section.get(key, default)
168
+ if isinstance(value, bool):
169
+ return value
170
+ if isinstance(value, str):
171
+ normalized = value.strip().lower()
172
+ if normalized in {"true", "1", "yes", "on"}:
173
+ return True
174
+ if normalized in {"false", "0", "no", "off"}:
175
+ return False
176
+ return default
177
+
178
+ def get_party_mode_v2_config(repo_root: Path | None = None) -> dict[str, int | bool]:
179
+ """Get Party Mode V2 runtime-board defaults."""
180
+ config = _load_config(repo_root)
181
+ section = _get_section(config, "party_mode_v2")
182
+ min_agents = _get_int(
183
+ section,
184
+ "min_agents",
185
+ DEFAULT_PARTY_MODE_V2_MIN_AGENTS,
186
+ minimum=3,
187
+ )
188
+ max_agents = _get_int(
189
+ section,
190
+ "max_agents",
191
+ DEFAULT_PARTY_MODE_V2_MAX_AGENTS,
192
+ minimum=min_agents,
193
+ )
194
+ return {
195
+ "min_agents": min_agents,
196
+ "max_agents": max_agents,
197
+ "max_rounds": _get_int(
198
+ section,
199
+ "max_rounds",
200
+ DEFAULT_PARTY_MODE_V2_MAX_ROUNDS,
201
+ minimum=1,
202
+ ),
203
+ "max_rebuttal_targets_per_agent": _get_int(
204
+ section,
205
+ "max_rebuttal_targets_per_agent",
206
+ DEFAULT_PARTY_MODE_V2_MAX_REBUTTAL_TARGETS_PER_AGENT,
207
+ minimum=1,
208
+ ),
209
+ "max_drift_warnings": _get_int(
210
+ section,
211
+ "max_drift_warnings",
212
+ DEFAULT_PARTY_MODE_V2_MAX_DRIFT_WARNINGS,
213
+ minimum=0,
214
+ ),
215
+ "fresh_context_per_round": _get_bool(
216
+ section,
217
+ "fresh_context_per_round",
218
+ DEFAULT_PARTY_MODE_V2_FRESH_CONTEXT_PER_ROUND,
219
+ ),
220
+ "require_current_round_only": _get_bool(
221
+ section,
222
+ "require_current_round_only",
223
+ DEFAULT_PARTY_MODE_V2_REQUIRE_CURRENT_ROUND_ONLY,
224
+ ),
225
+ }
226
+
227
+ def get_party_mode_v2_min_agents(repo_root: Path | None = None) -> int:
228
+ return int(get_party_mode_v2_config(repo_root)["min_agents"])
229
+
230
+ def get_party_mode_v2_max_agents(repo_root: Path | None = None) -> int:
231
+ return int(get_party_mode_v2_config(repo_root)["max_agents"])
232
+
233
+ def get_party_mode_v2_max_rounds(repo_root: Path | None = None) -> int:
234
+ return int(get_party_mode_v2_config(repo_root)["max_rounds"])
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """Shared gate result helpers for cowork-flow lifecycle commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass
11
+ class GateResult:
12
+ """Normalized result returned by workflow gates."""
13
+
14
+ scope: str
15
+ task_dir: Path | None = None
16
+ violations: list[dict] = field(default_factory=list)
17
+
18
+ @classmethod
19
+ def from_violations(
20
+ cls,
21
+ scope: str,
22
+ violations: list[dict],
23
+ task_dir: Path | None = None,
24
+ ) -> "GateResult":
25
+ return cls(scope=scope, task_dir=task_dir, violations=list(violations))
26
+
27
+ @property
28
+ def blockers(self) -> list[dict]:
29
+ return [
30
+ violation
31
+ for violation in self.violations
32
+ if violation.get("severity") == "block"
33
+ ]
34
+
35
+ @property
36
+ def blocked(self) -> bool:
37
+ return bool(self.blockers)
38
+
39
+ @property
40
+ def exit_code(self) -> int:
41
+ return 1 if self.blocked else 0
42
+
43
+
44
+ class GateRunner:
45
+ """Run existing validators through a common gate interface."""
46
+
47
+ def __init__(self, repo_root: Path) -> None:
48
+ self.repo_root = Path(repo_root)
49
+
50
+ def run(self, scope: str, task_dir: Path | None = None) -> GateResult:
51
+ from .validate_rules import validate_rules
52
+
53
+ normalized_task_dir = Path(task_dir) if task_dir is not None else None
54
+ violations = validate_rules(self.repo_root, scope, normalized_task_dir)
55
+ if scope in {"task_review", "task_complete"}:
56
+ from .validate_coding_standards import validate_coding_standards
57
+
58
+ violations.extend(
59
+ validate_coding_standards(self.repo_root, normalized_task_dir)
60
+ )
61
+ return GateResult.from_violations(scope, violations, normalized_task_dir)
62
+
63
+ def log(self, result: GateResult) -> None:
64
+ from .validate_rules import log_violations
65
+
66
+ log_violations(result.violations, result.scope, result.task_dir, self.repo_root)
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env python3
2
+ """Git changed-file snapshot helpers for workflow gates."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from .git_context import _run_git_command
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ChangedFile:
14
+ """A file currently changed in git status."""
15
+
16
+ path: str
17
+ statuses: tuple[str, ...]
18
+
19
+
20
+ def collect_changed_files(repo_root: Path) -> list[ChangedFile]:
21
+ """Return modified, staged, and untracked files from git status."""
22
+ rc, stdout, _ = _run_git_command(
23
+ ["status", "--porcelain=v1", "-uall", "--", "."],
24
+ cwd=repo_root,
25
+ )
26
+ if rc != 0:
27
+ return []
28
+
29
+ changed: dict[str, set[str]] = {}
30
+ for raw_line in stdout.splitlines():
31
+ parsed = _parse_status_line(raw_line)
32
+ if parsed is None:
33
+ continue
34
+ path, statuses = parsed
35
+ changed.setdefault(path, set()).update(statuses)
36
+
37
+ return [
38
+ ChangedFile(path=path, statuses=tuple(sorted(statuses)))
39
+ for path, statuses in sorted(changed.items())
40
+ ]
41
+
42
+
43
+ def collect_changed_paths(repo_root: Path) -> list[str]:
44
+ """Return only the relative paths from collect_changed_files."""
45
+ return [changed.path for changed in collect_changed_files(repo_root)]
46
+
47
+
48
+ def _parse_status_line(raw_line: str) -> tuple[str, tuple[str, ...]] | None:
49
+ if len(raw_line) < 4:
50
+ return None
51
+
52
+ code = raw_line[:2]
53
+ raw_path = raw_line[3:]
54
+ if " -> " in raw_path:
55
+ raw_path = raw_path.split(" -> ", 1)[1]
56
+
57
+ path = _normalize_git_path(raw_path)
58
+ if not path:
59
+ return None
60
+
61
+ statuses: list[str] = []
62
+ index_status = code[0]
63
+ worktree_status = code[1]
64
+
65
+ if code == "??":
66
+ statuses.append("untracked")
67
+ else:
68
+ if index_status not in (" ", "?"):
69
+ statuses.append("staged")
70
+ if worktree_status not in (" ", "?"):
71
+ statuses.append("modified")
72
+
73
+ if not statuses:
74
+ return None
75
+
76
+ return path, tuple(statuses)
77
+
78
+
79
+ def _normalize_git_path(raw_path: str) -> str:
80
+ path = raw_path.strip()
81
+ if len(path) >= 2 and path[0] == path[-1] == '"':
82
+ path = path[1:-1]
83
+ return path.replace("\\", "/")
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Task lifecycle state transition rules."""
3
+
4
+ from __future__ import annotations
5
+
6
+ CHECK_STATUSES = ("review", "checking")
7
+ DONE_STATUSES = ("completed", "done")
8
+
9
+ ALLOWED_TRANSITIONS = {
10
+ ("planning", "in_progress"),
11
+ ("in_progress", "review"),
12
+ ("review", "completed"),
13
+ ("checking", "completed"),
14
+ }
15
+
16
+
17
+ def _normalize_status(status: str | None) -> str:
18
+ if isinstance(status, str) and status.strip():
19
+ return status.strip()
20
+ return "unknown"
21
+
22
+
23
+ def transition_blockers(current_status: str | None, target_status: str) -> list[str]:
24
+ """Return blockers for a task status transition."""
25
+ current = _normalize_status(current_status)
26
+ target = _normalize_status(target_status)
27
+
28
+ if current == target:
29
+ return []
30
+
31
+ if (current, target) in ALLOWED_TRANSITIONS:
32
+ return []
33
+
34
+ if target == "completed":
35
+ return [
36
+ f"Task cannot be completed from status '{current}'. "
37
+ "Run `task review` first to enter check phase."
38
+ ]
39
+
40
+ if target == "review":
41
+ return [
42
+ f"Task cannot enter review from status '{current}'. "
43
+ "Run `task start` and finish implementation first."
44
+ ]
45
+
46
+ if target == "in_progress" and current in (*CHECK_STATUSES, *DONE_STATUSES):
47
+ return [
48
+ f"Task cannot return to in_progress from status '{current}'. "
49
+ "Create a follow-up task instead of reopening checked work."
50
+ ]
51
+
52
+ return [f"Task transition is not allowed: {current} -> {target}."]
53
+
54
+
55
+ def transition_allowed(current_status: str | None, target_status: str) -> bool:
56
+ """Return whether a task status transition is allowed."""
57
+ return not transition_blockers(current_status, target_status)