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.
- package/README.md +16 -8
- package/package.json +1 -1
- package/src/lib/copy-template.js +2 -2
- package/template/.agents/skills/before-dev/SKILL.md +1 -0
- package/template/.agents/skills/check/SKILL.md +1 -0
- package/template/.agents/skills/party-mode-v2/SKILL.md +75 -0
- package/template/.agents/skills/tdd/SKILL.md +61 -0
- package/template/.agents/skills/writing-plans/SKILL.md +1 -0
- package/template/.claude/agents/cowork-implement.md +153 -0
- package/template/.claude/hooks/inject-workflow-state.py +17 -8
- package/template/.claude/skills/before-dev/SKILL.md +1 -0
- package/template/.claude/skills/check/SKILL.md +1 -0
- package/template/.claude/skills/party-mode-v2/SKILL.md +75 -0
- package/template/.claude/skills/tdd/SKILL.md +61 -0
- package/template/.claude/skills/writing-plans/SKILL.md +1 -0
- package/template/.codex/agents/cowork-check.toml +2 -0
- package/template/.codex/agents/cowork-implement.toml +2 -0
- package/template/.codex/hooks/inject-workflow-state.py +17 -8
- 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/coding_standards.py +209 -0
- package/template/.cowork-flow/scripts/common/config.py +93 -0
- package/template/.cowork-flow/scripts/common/gates.py +66 -0
- package/template/.cowork-flow/scripts/common/git_snapshot.py +83 -0
- package/template/.cowork-flow/scripts/common/state_machine.py +57 -0
- package/template/.cowork-flow/scripts/common/tdd_evidence.py +301 -0
- package/template/.cowork-flow/scripts/common/test_intent.py +265 -0
- package/template/.cowork-flow/scripts/common/validate_coding_standards.py +181 -0
- package/template/.cowork-flow/scripts/common/validate_implementation.py +243 -0
- package/template/.cowork-flow/scripts/common/validate_rules.py +465 -0
- package/template/.cowork-flow/scripts/doctor.py +18 -36
- package/template/.cowork-flow/scripts/party_mode_v2.py +1162 -0
- package/template/.cowork-flow/scripts/run.py +3 -0
- package/template/.cowork-flow/scripts/task.py +190 -0
- package/template/.cowork-flow/spec/contracts/index.md +12 -0
- package/template/.cowork-flow/spec/contracts/party-mode-v2-board.md +68 -0
- package/template/.cowork-flow/spec/{subagent-dispatch.md → contracts/subagent-dispatch.md} +2 -0
- package/template/.cowork-flow/spec/{workflow-state-templates.md → contracts/workflow-state-templates.md} +1 -1
- package/template/.cowork-flow/spec/index.md +15 -0
- package/template/.cowork-flow/spec/{registry.json → runtime/contract-registry.json} +17 -4
- package/template/.cowork-flow/spec/runtime/index.md +10 -0
- package/template/.cowork-flow/spec/runtime/rules.json +173 -0
- package/template/.cowork-flow/spec/schemas/index.md +10 -0
- package/template/.cowork-flow/spec/schemas/party-mode-v2-actions.schema.json +90 -0
- package/template/.cowork-flow/spec/schemas/rules.schema.json +80 -0
- package/template/.cowork-flow/workflow.md +13 -7
- package/template/.opencode/agents/cowork-implement.md +153 -0
- package/template/.opencode/commands/party-mode-v2.md +26 -0
- package/template/.opencode/plugins/cowork-flow.js +18 -7
- package/template/AGENTS.md +2 -6
- package/template/.cowork-flow/scripts/common/entry_classifier.py +0 -122
- /package/template/.cowork-flow/spec/{capabilities.md → contracts/capabilities.md} +0 -0
- /package/template/.cowork-flow/spec/{entry-contract.md → contracts/entry-contract.md} +0 -0
- /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
|
|
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
|
-
|
|
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.
|
|
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
|
#-------------------------------------------------------------------------------
|
|
@@ -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)
|