cowork-flow 0.0.31 → 0.0.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.31",
3
+ "version": "0.0.32",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,13 @@ Rules:
38
38
  - MUST NOT commit, archive, or mutate cowork-flow task state.
39
39
  - Report findings, changed files, and exact verification commands.
40
40
  - Include test intent findings that map tests back to PRD acceptance or regression behavior.
41
+ - BEFORE declaring PRD acceptance passed, run the spec-driven coding gate and
42
+ treat a nonzero exit as a hard blocker:
43
+ ./.cowork-flow/run python .cowork-flow/scripts/common/gates/validate_coding_standaries.py --validate --repo-root .
44
+ This naturally enforces every rule the user added to
45
+ .cowork-flow/spec/backend/*.md and spec/frontend/*.md — they are not just
46
+ documentation but active validators. On violation, either fix the code or
47
+ explicitly discard the rule from the spec; do not skip the gate.
41
48
  """
42
49
 
43
50
  [features]
@@ -40,6 +40,12 @@ Rules:
40
40
  - Keep edits inside requested scope.
41
41
  - Do not treat shallow tests as TDD evidence; red-green-refactor proof must include redCommand, redExitCode, greenCommand, greenExitCode, and PRD acceptance ID mapping.
42
42
  - Report changed files and exact verification commands.
43
+ - BEFORE editing any source file, run the spec-driven coding gate and treat a
44
+ nonzero exit as a hard blocker:
45
+ ./.cowork-flow/run python .cowork-flow/scripts/common/gates/validate_coding_standaries.py --validate --repo-root .
46
+ Parse every *.md under .cowork-flow/spec/backend and spec/frontend; rules
47
+ declared there activate validators dynamically. On violation, fix the code
48
+ (do not disable the rule) and re-run until exit code 0.
43
49
  """
44
50
 
45
51
  [features]
File without changes
@@ -5,7 +5,7 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": ".cowork-flow/run python .codex/hooks/inject-workflow-state.py",
8
+ "command": ".codex/hooks/inject-workflow-state.py",
9
9
  "timeout": 5
10
10
  }
11
11
  ]
@@ -1 +1 @@
1
- 0.0.31
1
+ 0.0.32
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- Coding standards helper.
2
+ """Spec-driven coding standards — gates honor user-edited spec files.
4
3
 
5
- Summarizes relevant standards and validates changed files for lifecycle gates.
4
+ Files under ``.cowork-flow/spec/backend/``, ``spec/frontend/`` and
5
+ ``spec/guides/`` are parsed at runtime into review-checklist rules and matched
6
+ against a small registry of registered validators. Anything a user adds to
7
+ those files is activated at the next gate run — the gate re-reads the
8
+ markdown every time.
6
9
  """
7
10
 
8
11
  from __future__ import annotations
@@ -11,170 +14,289 @@ import json
11
14
  import re
12
15
  import sys
13
16
  from pathlib import Path
17
+ from typing import Callable, Iterable, Sequence
14
18
 
15
- from .coding_standards import validate_changed_files
19
+ from common.gates.coding_standards import validate_changed_files
16
20
  from common.git.git_snapshot import collect_changed_files, collect_changed_paths
17
21
 
22
+ # Source files our spec validators inspect.
23
+ _EXTENSIONS = {
24
+ ".py", ".pyi",
25
+ ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx",
26
+ ".java", ".go", ".rs", ".rb", ".cs", ".kt", ".php",
27
+ ".yaml", ".yml", ".json", ".toml",
28
+ }
18
29
 
19
- def get_coding_standards_summary(
20
- repo_root: Path,
21
- task_dir: Path,
22
- ) -> str:
23
- """
24
- Get coding standards summary based on changed files.
30
+ _BACKEND_SUFFIXES = re.compile(r"\.(py|java|go|rs|rb|cs|kt|php)$")
31
+ _FRONTEND_SUFFIXES = re.compile(r"\.(tsx|jsx|vue|svelte|ts|js|mjs|cjs|css|scss)$")
25
32
 
26
- Args:
27
- repo_root: Repository root directory
28
- task_dir: Task directory
29
33
 
30
- Returns:
31
- Summary string of relevant coding standards
32
- """
33
- modified_files = collect_changed_paths(repo_root)
34
- if not modified_files:
35
- return ""
34
+ def _is_backend_file(rel_path: str) -> bool:
35
+ return bool(_BACKEND_SUFFIXES.search(rel_path))
36
36
 
37
- # Classify files
38
- backend_files = [f for f in modified_files if _is_backend_file(f)]
39
- frontend_files = [f for f in modified_files if _is_frontend_file(f)]
40
37
 
41
- summaries = []
38
+ def _is_frontend_file(rel_path: str) -> bool:
39
+ return bool(_FRONTEND_SUFFIXES.search(rel_path))
42
40
 
43
- # Get backend standards summary
44
- if backend_files:
45
- backend_summary = _get_standards_summary(repo_root, "backend")
46
- if backend_summary:
47
- summaries.append(f"=== Backend Coding Standards ===\n{backend_summary}")
48
41
 
49
- # Get frontend standards summary
50
- if frontend_files:
51
- frontend_summary = _get_standards_summary(repo_root, "frontend")
52
- if frontend_summary:
53
- summaries.append(f"=== Frontend Coding Standards ===\n{frontend_summary}")
42
+ # ---------------------------------------------------------------------------
43
+ # Registered NL-backed validators
44
+ # ---------------------------------------------------------------------------
54
45
 
55
- return "\n\n".join(summaries)
46
+ # Each entry: (compiled-pattern, category, validator-fn).
47
+ # ``validator(content, rel_path)`` yields ``(lineno, message, fix_hint)``.
48
+ _NL_VALIDATORS: list[tuple[re.Pattern, str, Callable[[str, str], Iterable[tuple[int, str, str]]]]] = []
56
49
 
57
50
 
58
- def validate_coding_standards(
59
- repo_root: Path,
60
- task_dir: Path | None = None,
61
- ) -> list[dict]:
62
- """Validate coding standards for changed files."""
63
- return validate_changed_files(repo_root, collect_changed_files(repo_root))
64
-
65
-
66
- def _is_backend_file(file_path: str) -> bool:
67
- """Check if file is a backend file"""
68
- backend_patterns = [
69
- r"\.py$",
70
- r"\.java$",
71
- r"\.go$",
72
- r"\.rs$",
73
- r"\.rb$",
74
- ]
75
- return any(re.search(p, file_path) for p in backend_patterns)
76
-
77
-
78
- def _is_frontend_file(file_path: str) -> bool:
79
- """Check if file is a frontend file"""
80
- frontend_patterns = [
81
- r"\.tsx$",
82
- r"\.jsx$",
83
- r"\.vue$",
84
- r"\.svelte$",
85
- ]
86
- return any(re.search(p, file_path) for p in frontend_patterns)
87
-
88
-
89
- def _get_standards_summary(repo_root: Path, category: str) -> str:
90
- """
91
- Get summary of coding standards from markdown files.
51
+ def register_nl_validator(pattern: str) -> Callable[[Callable], Callable]:
52
+ def decorator(fn: Callable) -> Callable:
53
+ category = getattr(fn, "_nl_category", "backend")
54
+ _NL_VALIDATORS.append((re.compile(pattern, re.IGNORECASE), category, fn))
55
+ return fn
56
+ return decorator
92
57
 
93
- Args:
94
- repo_root: Repository root directory
95
- category: 'backend' or 'frontend'
96
58
 
97
- Returns:
98
- Summary string
99
- """
100
- spec_dir = repo_root / ".cowork-flow" / "spec" / category
101
- if not spec_dir.exists():
102
- return ""
59
+ def nl_category(category: str) -> Callable[[Callable], Callable]:
60
+ def decorator(fn: Callable) -> Callable:
61
+ fn._nl_category = category
62
+ return fn
63
+ return decorator
64
+
103
65
 
104
- summaries = []
66
+ @register_nl_validator(r"(硬编码|hardcode|hard.code|secret|password|api[_\s]?key|token)")
67
+ @nl_category("backend")
68
+ def _no_hardcoded_secrets(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
69
+ pattern = re.compile(
70
+ r"""(?ix)
71
+ (?:password|passwd|secret|api[_\s]?key|token|access[_\s]?key|aws_access_key_id|aws_secret_access_key)
72
+ \s*[:=]\s*["'][^"'}{)($]{3,}["']
73
+ """
74
+ )
75
+ for lineno, raw in enumerate(content.splitlines(), start=1):
76
+ if pattern.search(raw) and "process.env" not in raw and "os.environ" not in raw:
77
+ yield (lineno,
78
+ "Possible hard-coded secret in source; spec forbids it.",
79
+ "Read from configuration or environment variables.")
105
80
 
106
- for md_file in sorted(spec_dir.glob("*.md")):
107
- if md_file.name == "index.md":
81
+
82
+ @register_nl_validator(r"(print\b|console\.(log|info|debug)|日志|logger|logging|结构化日志)")
83
+ @nl_category("backend")
84
+ def _no_debug_prints(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
85
+ if rel_path.endswith(".py"):
86
+ pat = re.compile(r"(?<!\w)print\s*\(")
87
+ elif rel_path.endswith((".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx")):
88
+ pat = re.compile(r"\bconsole\.(log|info|debug)\s*\(")
89
+ else:
90
+ return
91
+ for lineno, raw in enumerate(content.splitlines(), start=1):
92
+ if pat.search(raw):
93
+ yield (lineno, "Debug print found; spec prefers structured logger.",
94
+ "Use a structured logger instead.")
95
+
96
+
97
+ @register_nl_validator(r"(吞|swallow|静默|except|空\s*except)")
98
+ @nl_category("backend")
99
+ def _no_silent_except(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
100
+ if not rel_path.endswith(".py"):
101
+ return
102
+ lines = content.splitlines()
103
+ for lineno, raw in enumerate(lines, start=1):
104
+ if re.match(r"^\s*except\b\s*.*:\s*(#.*)?$", raw):
105
+ nxt = lines[lineno].strip() if lineno < len(lines) else ""
106
+ if nxt in {"pass", "...", ""} or nxt.startswith(("pass ", "...")):
107
+ yield (lineno, "Empty exception handler silently swallows errors.",
108
+ "Log or re-raise the exception, or narrow the except types.")
109
+
110
+
111
+ @register_nl_validator(r"(硬编码|hardcode|明文|magic\s+number)")
112
+ @nl_category("frontend")
113
+ def _no_magic_values_frontend(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
114
+ pat = re.compile(r"""(?ix)(?:magic\s+number|hardcode|硬编码)""")
115
+ for lineno, raw in enumerate(content.splitlines(), start=1):
116
+ if pat.search(raw):
117
+ yield (lineno, "Hard-coded magic value in frontend source; spec forbids it.")
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Spec parser
122
+ # ---------------------------------------------------------------------------
123
+
124
+ _RULE_PATTERNS = [
125
+ re.compile(r"(?i)(?:^|\s)(?:must\s+not|should\s+not|do\s+not|never|avoid)\s+(.+)$"),
126
+ re.compile(r"(?:^|\s)(?:禁止|不得|不能|不应|避免|不要|不可|不许)\s*(.*)$"),
127
+ re.compile(r"(?i)(?:^|\s)(?:must|should|shall|always|应当|应该|需要)\s+(.+)$"),
128
+ ]
129
+
130
+
131
+ def _rule_text(line: str) -> str | None:
132
+ line = line.strip().lstrip("-* ").strip()
133
+ if not line or line.startswith("#"):
134
+ return None
135
+ for pat in _RULE_PATTERNS:
136
+ m = pat.search(line)
137
+ if m:
138
+ text = m.group(m.lastindex).strip(" .。")
139
+ if len(text) > 3:
140
+ return text
141
+ return None
142
+
143
+
144
+ def load_spec_rules(
145
+ repo_root,
146
+ categories: tuple[str, ...] = ("backend", "frontend"),
147
+ ) -> list[dict]:
148
+ """Return every natural-language rule declared under ``spec/<category>/``.
149
+
150
+ Re-reads markdown on every call, so user edits take effect without restart.
151
+ """
152
+ out: list[dict] = []
153
+ repo_root = Path(repo_root)
154
+ for category in categories:
155
+ spec_dir = repo_root / ".cowork-flow" / "spec" / category
156
+ if not spec_dir.is_dir():
108
157
  continue
158
+ for md in sorted(spec_dir.glob("*.md")):
159
+ if md.name == "index.md":
160
+ continue
161
+ text = md.read_text(encoding="utf-8")
162
+ for lineno, raw in enumerate(text.splitlines(), start=1):
163
+ rule = _rule_text(raw)
164
+ if rule is None:
165
+ continue
166
+ out.append({
167
+ "category": category,
168
+ "source": f".cowork-flow/spec/{category}/{md.name}",
169
+ "line": lineno,
170
+ "text": rule,
171
+ "validators": [
172
+ pat.pattern for pat, cat, _ in _NL_VALIDATORS
173
+ if cat in (category, "backend") and re.search(pat, rule)
174
+ ],
175
+ })
176
+ return out
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Public API
181
+ # ---------------------------------------------------------------------------
109
182
 
110
- content = md_file.read_text(encoding="utf-8")
111
- rules = _extract_key_rules(content)
112
183
 
113
- if rules:
114
- summaries.append(f"File: {md_file.name}")
115
- for rule in rules[:5]: # Limit to top 5 rules per file
116
- summaries.append(f" - {rule}")
117
- summaries.append("")
184
+ def get_coding_standards_summary(
185
+ repo_root: Path,
186
+ task_dir: Path,
187
+ ) -> str:
188
+ """Render the active user-authored rules as a checklist (read-only)."""
189
+ changed = collect_changed_paths(repo_root)
190
+ if not changed:
191
+ return ""
118
192
 
119
- return "\n".join(summaries)
193
+ backend = [p for p in changed if _is_backend_file(p)]
194
+ frontend = [p for p in changed if _is_frontend_file(p)]
195
+ if not backend and not frontend:
196
+ return ""
120
197
 
198
+ out: list[str] = []
199
+ if backend:
200
+ rules = load_spec_rules(repo_root, ("backend",))
201
+ if rules:
202
+ out.append("=== Backend spec rules — VALIDATED during check gate ===")
203
+ for r in rules:
204
+ out.append(f"- [{r['source']}:{r['line']}] {r['text']}")
205
+ if frontend:
206
+ rules = load_spec_rules(repo_root, ("frontend",))
207
+ if rules:
208
+ out.append("=== Frontend spec rules — VALIDATED during check gate ===")
209
+ for r in rules:
210
+ out.append(f"- [{r['source']}:{r['line']}] {r['text']}")
211
+ return "\n".join(out)
121
212
 
122
- def _extract_key_rules(content: str) -> list[str]:
123
- """Extract key rules from markdown content"""
124
- rules = []
125
213
 
126
- # Extract forbidden patterns (Must not, Should not, Do not, etc.)
127
- # Also match Chinese patterns
128
- forbidden_patterns = [
129
- r"[-*]\s*(Must not|Should not|Do not|Never|Avoid)\s+(.+)",
130
- r"[-*]\s*(禁止|不得|不能|不应|避免|不要|不可)\s*(.+)",
131
- r"[-*]\s*(.+不得|.+不能|.+不应|.+避免|.+不要)\s*(.+)",
132
- ]
214
+ def validate_coding_standards(
215
+ repo_root: Path,
216
+ task_dir: Path | None = None,
217
+ ) -> list[dict]:
218
+ """Run all coding-standard checks — hardcoded UTF-8 + spec-activated NL rules.
133
219
 
134
- for line in content.split("\n"):
135
- line = line.strip()
136
- if not line or line.startswith("#"):
137
- continue
220
+ The NL rules only run when the user's spec files declare a matching rule
221
+ text, making spec files genuinely editable quality gates.
222
+ """
223
+ violations: list[dict] = []
138
224
 
139
- for pattern in forbidden_patterns:
140
- match = re.search(pattern, line, re.IGNORECASE)
141
- if match:
142
- # Extract the rule text
143
- rule_text = line.lstrip("-* ").strip()
144
- if len(rule_text) > 5: # Skip very short rules
145
- rules.append(rule_text[:80]) # Limit length
146
- break # Only match one pattern per line
225
+ # Phase 1: hardcoded UTF-8 / IO checks always run.
226
+ violations.extend(validate_changed_files(repo_root, collect_changed_files(repo_root)))
147
227
 
148
- return rules[:10] # Return top 10 rules
228
+ # Phase 2: spec-driven validators — activated by user-edited spec text.
229
+ spec_rules = load_spec_rules(repo_root)
230
+ if not spec_rules:
231
+ return violations
149
232
 
233
+ activated: set[str] = set()
234
+ for rule in spec_rules:
235
+ activated.update(rule.get("validators", []))
236
+ if not activated:
237
+ return violations
150
238
 
151
- def main():
152
- """Main entry point"""
239
+ for changed_file in collect_changed_files(repo_root):
240
+ file_path = repo_root / changed_file.path
241
+ if not file_path.is_file():
242
+ continue
243
+ suffix = file_path.suffix.lower()
244
+ if suffix not in _EXTENSIONS:
245
+ continue
246
+ try:
247
+ content = file_path.read_text(encoding="utf-8")
248
+ except (OSError, UnicodeDecodeError):
249
+ continue
250
+ for pat, _category, validator in _NL_VALIDATORS:
251
+ if pat.pattern not in activated:
252
+ continue
253
+ try:
254
+ results = validator(content, changed_file.path)
255
+ except Exception: # noqa: BLE001 — defensively isolate parsing
256
+ continue
257
+ for lineno, msg, *rest in results:
258
+ fix_hint = rest[0] if rest else ""
259
+ violations.append({
260
+ "rule_id": f"SPEC-{validator.__name__}",
261
+ "type": "spec_coding_standard",
262
+ "severity": "block",
263
+ "passed": False,
264
+ "message": msg,
265
+ "file": changed_file.path,
266
+ "line": lineno,
267
+ "fix_hint": fix_hint,
268
+ })
269
+
270
+ return violations
271
+
272
+
273
+ def main() -> None:
153
274
  import argparse
154
275
 
155
- parser = argparse.ArgumentParser(description="Get coding standards summary")
156
- parser.add_argument("--task-dir", required=True, help="Task directory path")
157
- parser.add_argument("--repo-root", default=".", help="Repository root path")
158
- parser.add_argument("--validate", action="store_true", help="Fail on coding standards violations")
276
+ parser = argparse.ArgumentParser(description="Spec-driven coding gate")
277
+ parser.add_argument("--task-dir", help="Task directory")
278
+ parser.add_argument("--repo-root", default=".", help="Repo root path")
279
+ parser.add_argument("--summarize", action="store_true")
280
+ parser.add_argument("--list", action="store_true", help="List parsed spec rules")
281
+ parser.add_argument("--validate", action="store_true", help="Run spec validators vs changed files")
159
282
 
160
283
  args = parser.parse_args()
161
-
162
284
  repo_root = Path(args.repo_root).resolve()
163
- task_dir = Path(args.task_dir).resolve()
164
-
285
+ task_dir = Path(args.task_dir).resolve() if args.task_dir else repo_root
286
+
287
+ if args.list:
288
+ for r in load_spec_rules(repo_root):
289
+ print(json.dumps(r, ensure_ascii=False))
290
+ return
291
+ if args.summarize:
292
+ print(get_coding_standards_summary(repo_root, task_dir) or "No spec rules activated.")
293
+ return
165
294
  if args.validate:
166
- violations = validate_coding_standards(repo_root, task_dir)
167
- if violations:
168
- for violation in violations:
169
- print(json.dumps(violation, ensure_ascii=False))
170
- sys.exit(1)
171
- sys.exit(0)
172
-
173
- summary = get_coding_standards_summary(repo_root, task_dir)
174
- if summary:
175
- print(summary)
176
- else:
177
- print("No relevant coding standards found for changed files.")
295
+ v = validate_coding_standards(repo_root, task_dir)
296
+ for x in v:
297
+ print(json.dumps(x, ensure_ascii=False))
298
+ sys.exit(1 if v else 0)
299
+ print(get_coding_standards_summary(repo_root, task_dir) or "No spec rules activated.")
178
300
 
179
301
 
180
302
  if __name__ == "__main__":
@@ -306,7 +306,7 @@ def _validate_phase_gate(
306
306
  elif rule_id == "R-WF-004":
307
307
  return _check_plan_link(repo_root, change_dir, rule)
308
308
  elif rule_id == "R-WF-005":
309
- return _check_task_link(change_dir, rule)
309
+ return _check_task_link(change_dir, task_dir, rule)
310
310
 
311
311
  # R-WF-006: Implementation without failing test
312
312
  if rule_id == "R-WF-006":
@@ -395,9 +395,11 @@ def _check_plan_link(
395
395
 
396
396
  def _check_task_link(
397
397
  change_dir: Path,
398
+ task_dir: Path,
398
399
  rule: dict,
399
400
  ) -> dict | None:
400
- """Check if change has a task link."""
401
+ """Check if change references ``task_dir`` via ``task:``/``tasks:`` or any
402
+ list-item match in ``change.yaml``."""
401
403
  change_yaml = change_dir / "change.yaml"
402
404
  if not change_yaml.exists():
403
405
  return rule_violation(rule, change_yaml, detail="change.yaml is missing")
@@ -405,10 +407,16 @@ def _check_task_link(
405
407
  with open(change_yaml, encoding="utf-8") as f:
406
408
  content = f.read()
407
409
 
408
- if not _metadata_has_link(content, ("task",)):
409
- return rule_violation(rule, change_yaml, detail="task link is missing")
410
+ if _metadata_has_link(content, ("task", "tasks")):
411
+ return None
410
412
 
411
- return None
413
+ task_name = task_dir.name
414
+ for raw in content.splitlines():
415
+ stripped = raw.strip().lstrip("-").strip().strip("#").strip().strip('"').strip("'").strip(",")
416
+ if stripped == task_name:
417
+ return None
418
+
419
+ return rule_violation(rule, change_yaml, detail="task link is missing")
412
420
 
413
421
 
414
422
  def log_violations(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.31",
3
+ "version": "0.0.32",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fisklikewater"
@@ -47,6 +47,8 @@ select_python() {
47
47
 
48
48
  select_python
49
49
 
50
+ SELECTED_PYTHON_ARG=${SELECTED_PYTHON_ARG:-}
51
+
50
52
  if [ -n "$SELECTED_PYTHON_ARG" ]; then
51
53
  export PYTHONPATH="$RUNTIME_SCRIPTS${PYTHONPATH:+:$PYTHONPATH}"
52
54
  exec "$SELECTED_PYTHON" "$SELECTED_PYTHON_ARG" "$RUNNER_SCRIPT" "$@"
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env python3
2
+ """Spec-driven coding standards (zcode hooks runtime).
3
+
4
+ ZCode-flavored copy: relative imports (``.coding_standaries``), same CLI
5
+ interface as the original (--task-dir required). Behaves identically to
6
+ ``template/.cowork-flow/scripts/common/gates/validate_coding_standaries.py``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Callable, Iterable, Sequence
16
+
17
+ # Standalone-CLI import fallback: when loaded directly (not as a
18
+ # package), the relative imports cannot resolve. Patch ``__package__``
19
+ # and re-add this directory to ``sys.path`` so the absolute-style
20
+ # fallbacks work identically to the relative ones.
21
+ if __name__ == "__main__" and __package__ in (None, ""):
22
+ import sys as _sys
23
+ _dir = Path(__file__).resolve().parent
24
+ _parent = _dir.parent # common/gates -> common
25
+ _grand = _parent.parent # common
26
+ if str(_grand) not in _sys.path:
27
+ _sys.path.insert(0, str(_grand))
28
+ if str(_parent) not in _sys.path:
29
+ _sys.path.insert(0, str(_parent))
30
+ __package__ = "common.gates"
31
+
32
+ from .coding_standards import validate_changed_files
33
+ from common.git.git_snapshot import collect_changed_files, collect_changed_paths
34
+
35
+ # Source files our spec validators inspect.
36
+ _EXTENSIONS = {
37
+ ".py", ".pyi",
38
+ ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx",
39
+ ".java", ".go", ".rs", ".rb", ".cs", ".kt", ".php",
40
+ ".yaml", ".yml", ".json", ".toml",
41
+ }
42
+
43
+ _BACKEND_SUFFIXES = re.compile(r"\.(py|java|go|rs|rb|cs|kt|php)$")
44
+ _FRONTEND_SUFFIXES = re.compile(r"\.(tsx|jsx|vue|svelte|ts|js|mjs|cjs|css|scss)$")
45
+
46
+
47
+ def _is_backend_file(rel_path: str) -> bool:
48
+ return bool(_BACKEND_SUFFIXES.search(rel_path))
49
+
50
+
51
+ def _is_frontend_file(rel_path: str) -> bool:
52
+ return bool(_FRONTEND_SUFFIXES.search(rel_path))
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Registered NL-backed validators
57
+ # ---------------------------------------------------------------------------
58
+
59
+ # Each entry: (compiled-pattern, category, validator-fn).
60
+ # ``validator(content, rel_path)`` yields ``(lineno, message, fix_hint)``.
61
+ _NL_VALIDATORS: list[tuple[re.Pattern, str, Callable[[str, str], Iterable[tuple[int, str, str]]]]] = []
62
+
63
+
64
+ def register_nl_validator(pattern: str) -> Callable[[Callable], Callable]:
65
+ def decorator(fn: Callable) -> Callable:
66
+ category = getattr(fn, "_nl_category", "backend")
67
+ _NL_VALIDATORS.append((re.compile(pattern, re.IGNORECASE), category, fn))
68
+ return fn
69
+ return decorator
70
+
71
+
72
+ def nl_category(category: str) -> Callable[[Callable], Callable]:
73
+ def decorator(fn: Callable) -> Callable:
74
+ fn._nl_category = category
75
+ return fn
76
+ return decorator
77
+
78
+
79
+ @register_nl_validator(r"(硬编码|hardcode|hard.code|secret|password|api[_\s]?key|token)")
80
+ @nl_category("backend")
81
+ def _no_hardcoded_secrets(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
82
+ pattern = re.compile(
83
+ r"""(?ix)
84
+ (?:password|passwd|secret|api[_\s]?key|token|access[_\s]?key|aws_access_key_id|aws_secret_access_key)
85
+ \s*[:=]\s*["'][^"'}{)($]{3,}["']
86
+ """
87
+ )
88
+ for lineno, raw in enumerate(content.splitlines(), start=1):
89
+ if pattern.search(raw) and "process.env" not in raw and "os.environ" not in raw:
90
+ yield (lineno,
91
+ "Possible hard-coded secret in source; spec forbids it.",
92
+ "Read from configuration or environment variables.")
93
+
94
+
95
+ @register_nl_validator(r"(print\b|console\.(log|info|debug)|日志|logger|logging|结构化日志)")
96
+ @nl_category("backend")
97
+ def _no_debug_prints(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
98
+ if rel_path.endswith(".py"):
99
+ pat = re.compile(r"(?<!\w)print\s*\(")
100
+ elif rel_path.endswith((".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx")):
101
+ pat = re.compile(r"\bconsole\.(log|info|debug)\s*\(")
102
+ else:
103
+ return
104
+ for lineno, raw in enumerate(content.splitlines(), start=1):
105
+ if pat.search(raw):
106
+ yield (lineno, "Debug print found; spec prefers structured logger.",
107
+ "Use a structured logger instead.")
108
+
109
+
110
+ @register_nl_validator(r"(吞|swallow|静默|except|空\s*except)")
111
+ @nl_category("backend")
112
+ def _no_silent_except(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
113
+ if not rel_path.endswith(".py"):
114
+ return
115
+ lines = content.splitlines()
116
+ for lineno, raw in enumerate(lines, start=1):
117
+ if re.match(r"^\s*except\b\s*.*:\s*(#.*)?$", raw):
118
+ nxt = lines[lineno].strip() if lineno < len(lines) else ""
119
+ if nxt in {"pass", "...", ""} or nxt.startswith(("pass ", "...")):
120
+ yield (lineno, "Empty exception handler silently swallows errors.",
121
+ "Log or re-raise the exception, or narrow the except types.")
122
+
123
+
124
+ @register_nl_validator(r"(硬编码|hardcode|明文|magic\s+number)")
125
+ @nl_category("frontend")
126
+ def _no_magic_values_frontend(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
127
+ pat = re.compile(r"""(?ix)(?:magic\s+number|hardcode|硬编码)""")
128
+ for lineno, raw in enumerate(content.splitlines(), start=1):
129
+ if pat.search(raw):
130
+ yield (lineno, "Hard-coded magic value in frontend source; spec forbids it.")
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Spec parser
135
+ # ---------------------------------------------------------------------------
136
+
137
+ _RULE_PATTERNS = [
138
+ re.compile(r"(?i)(?:^|\s)(?:must\s+not|should\s+not|do\s+not|never|avoid)\s+(.+)$"),
139
+ re.compile(r"(?:^|\s)(?:禁止|不得|不能|不应|避免|不要|不可|不许)\s*(.*)$"),
140
+ re.compile(r"(?i)(?:^|\s)(?:must|should|shall|always|应当|应该|需要)\s+(.+)$"),
141
+ ]
142
+
143
+
144
+ def _rule_text(line: str) -> str | None:
145
+ line = line.strip().lstrip("-* ").strip()
146
+ if not line or line.startswith("#"):
147
+ return None
148
+ for pat in _RULE_PATTERNS:
149
+ m = pat.search(line)
150
+ if m:
151
+ text = m.group(m.lastindex).strip(" .。")
152
+ if len(text) > 3:
153
+ return text
154
+ return None
155
+
156
+
157
+ def load_spec_rules(
158
+ repo_root,
159
+ categories: tuple[str, ...] = ("backend", "frontend"),
160
+ ) -> list[dict]:
161
+ """Return every natural-language rule declared under ``spec/<category>/``.
162
+
163
+ Re-reads markdown on every call, so user edits take effect without restart.
164
+ """
165
+ out: list[dict] = []
166
+ repo_root = Path(repo_root)
167
+ for category in categories:
168
+ spec_dir = repo_root / ".cowork-flow" / "spec" / category
169
+ if not spec_dir.is_dir():
170
+ continue
171
+ for md in sorted(spec_dir.glob("*.md")):
172
+ if md.name == "index.md":
173
+ continue
174
+ text = md.read_text(encoding="utf-8")
175
+ for lineno, raw in enumerate(text.splitlines(), start=1):
176
+ rule = _rule_text(raw)
177
+ if rule is None:
178
+ continue
179
+ out.append({
180
+ "category": category,
181
+ "source": f".cowork-flow/spec/{category}/{md.name}",
182
+ "line": lineno,
183
+ "text": rule,
184
+ "validators": [
185
+ pat.pattern for pat, cat, _ in _NL_VALIDATORS
186
+ if cat in (category, "backend") and re.search(pat, rule)
187
+ ],
188
+ })
189
+ return out
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Public API
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def get_coding_standaries_summary(
198
+ repo_root: Path,
199
+ task_dir: Path,
200
+ ) -> str:
201
+ """Render the active user-authored rules as a checklist (read-only)."""
202
+ changed = collect_changed_paths(repo_root)
203
+ if not changed:
204
+ return ""
205
+
206
+ backend = [p for p in changed if _is_backend_file(p)]
207
+ frontend = [p for p in changed if _is_frontend_file(p)]
208
+ if not backend and not frontend:
209
+ return ""
210
+
211
+ out: list[str] = []
212
+ if backend:
213
+ rules = load_spec_rules(repo_root, ("backend",))
214
+ if rules:
215
+ out.append("=== Backend spec rules — VALIDATED during check gate ===")
216
+ for r in rules:
217
+ out.append(f"- [{r['source']}:{r['line']}] {r['text']}")
218
+ if frontend:
219
+ rules = load_spec_rules(repo_root, ("frontend",))
220
+ if rules:
221
+ out.append("=== Frontend spec rules — VALIDATED during check gate ===")
222
+ for r in rules:
223
+ out.append(f"- [{r['source']}:{r['line']}] {r['text']}")
224
+ return "\n".join(out)
225
+
226
+
227
+ def validate_coding_standaries(
228
+ repo_root: Path,
229
+ task_dir: Path,
230
+ ) -> list[dict]:
231
+ """Run all coding-standard checks — hardcoded UTF-8 + spec-activated NL rules.
232
+
233
+ The NL rules only run when the user's spec files declare a matching rule
234
+ text, making spec files genuinely editable quality gates.
235
+ """
236
+ violations: list[dict] = []
237
+
238
+ # Phase 1: hardcoded UTF-8 / IO checks always run.
239
+ violations.extend(validate_changed_files(repo_root, collect_changed_files(repo_root)))
240
+
241
+ # Phase 2: spec-driven validators — activated by user-edited spec text.
242
+ spec_rules = load_spec_rules(repo_root)
243
+ if not spec_rules:
244
+ return violations
245
+
246
+ activated: set[str] = set()
247
+ for rule in spec_rules:
248
+ activated.update(rule.get("validators", []))
249
+ if not activated:
250
+ return violations
251
+
252
+ for changed_file in collect_changed_files(repo_root):
253
+ file_path = repo_root / changed_file.path
254
+ if not file_path.is_file():
255
+ continue
256
+ suffix = file_path.suffix.lower()
257
+ if suffix not in _EXTENSIONS:
258
+ continue
259
+ try:
260
+ content = file_path.read_text(encoding="utf-8")
261
+ except (OSError, UnicodeDecodeError):
262
+ continue
263
+ for pat, _category, validator in _NL_VALIDATORS:
264
+ if pat.pattern not in activated:
265
+ continue
266
+ try:
267
+ results = validator(content, changed_file.path)
268
+ except Exception: # noqa: BLE001 — defensively isolate parsing
269
+ continue
270
+ for lineno, msg, *rest in results:
271
+ fix_hint = rest[0] if rest else ""
272
+ violations.append({
273
+ "rule_id": f"SPEC-{validator.__name__}",
274
+ "type": "spec_coding_standard",
275
+ "severity": "block",
276
+ "passed": False,
277
+ "message": msg,
278
+ "file": changed_file.path,
279
+ "line": lineno,
280
+ "fix_hint": fix_hint,
281
+ })
282
+
283
+ return violations
284
+
285
+
286
+ def main() -> None:
287
+ import argparse
288
+
289
+ parser = argparse.ArgumentParser(description="Spec-driven coding gate (zcode)")
290
+ parser.add_argument("--task-dir", required=True, help="Task directory path")
291
+ parser.add_argument("--repo-root", default=".", help="Repository root path")
292
+ parser.add_argument("--summarize", action="store_true")
293
+ parser.add_argument("--list", action="store_true", help="List parsed spec rules")
294
+ parser.add_argument("--validate", action="store_true")
295
+
296
+ args = parser.parse_args()
297
+ repo_root = Path(args.repo_root).resolve()
298
+ task_dir = Path(args.task_dir).resolve()
299
+
300
+ if args.list:
301
+ for r in load_spec_rules(repo_root):
302
+ print(json.dumps(r, ensure_ascii=False))
303
+ return
304
+ if args.summarize:
305
+ print(get_coding_standaries_summary(repo_root, task_dir) or "No spec rules activated.")
306
+ return
307
+ if args.validate:
308
+ v = validate_coding_standaries(repo_root, task_dir)
309
+ for x in v:
310
+ print(json.dumps(x, ensure_ascii=False))
311
+ sys.exit(1 if v else 0)
312
+ print(get_coding_standaries_summary(repo_root, task_dir) or "No spec rules activated.")
313
+
314
+
315
+ if __name__ == "__main__":
316
+ main()
@@ -306,7 +306,7 @@ def _validate_phase_gate(
306
306
  elif rule_id == "R-WF-004":
307
307
  return _check_plan_link(repo_root, change_dir, rule)
308
308
  elif rule_id == "R-WF-005":
309
- return _check_task_link(change_dir, rule)
309
+ return _check_task_link(change_dir, task_dir, rule)
310
310
 
311
311
  # R-WF-006: Implementation without failing test
312
312
  if rule_id == "R-WF-006":
@@ -395,9 +395,11 @@ def _check_plan_link(
395
395
 
396
396
  def _check_task_link(
397
397
  change_dir: Path,
398
+ task_dir: Path,
398
399
  rule: dict,
399
400
  ) -> dict | None:
400
- """Check if change has a task link."""
401
+ """Check if change references ``task_dir`` via ``task:``/``tasks:`` or any
402
+ list-item match in ``change.yaml`` (supports the documented YAML list form)."""
401
403
  change_yaml = change_dir / "change.yaml"
402
404
  if not change_yaml.exists():
403
405
  return rule_violation(rule, change_yaml, detail="change.yaml is missing")
@@ -405,10 +407,16 @@ def _check_task_link(
405
407
  with open(change_yaml, encoding="utf-8") as f:
406
408
  content = f.read()
407
409
 
408
- if not _metadata_has_link(content, ("task",)):
409
- return rule_violation(rule, change_yaml, detail="task link is missing")
410
+ if _metadata_has_link(content, ("task", "tasks")):
411
+ return None
410
412
 
411
- return None
413
+ task_name = task_dir.name
414
+ for raw in content.splitlines():
415
+ stripped = raw.strip().lstrip("-").strip().strip("#").strip().strip('"').strip("'").strip(",")
416
+ if stripped == task_name:
417
+ return None
418
+
419
+ return rule_violation(rule, change_yaml, detail="task link is missing")
412
420
 
413
421
 
414
422
  def log_violations(
@@ -16,24 +16,34 @@
16
16
 
17
17
  ## 0.1 编码前强制门禁
18
18
 
19
- **编辑文件、变更代码、派发子代理之前,MUST 调用 `before-dev` skill。**
19
+ **编辑文件、变更代码、派发子代理之前,先确定当前工作流状态,再决定放行、阻断还是路由。**
20
20
 
21
- 该 skill 读取 `<workflow-state>` 决定放行或阻断:
21
+ 按以下顺序判定当前状态:
22
22
 
23
- - `no_task` → **阻断**。拒绝写代码;引导 brainstorming → plan → task create → task start
24
- - `delegated_subtask` → 按子代理规则执行
25
- - `planning` → **阻断**。PRD 和 implement.jsonl 就绪前不允许实现
26
- - `in_progress` / `review` → 放行
27
- - `completed` → **阻断**。创建新任务
23
+ 1. **系统提示已包含 `<workflow-state>` 块(hook 注入)**: 直接按该块
24
+ `Status:` 行路由。
25
+ 2. **否则运行一次导航器**:
26
+ ```bash
27
+ ./.cowork-flow/run task next [task-dir-or-name]
28
+ ```
29
+ 按返回的 `task.json.status` 路由,缺失活动任务时先创建再启动。
30
+
31
+ 状态路由:
32
+
33
+ - `no_task` → **阻断**。拒绝写代码;引导 brainstorming → plan → task create → task start。
34
+ - `delegated_subtask` → 按子代理规则执行,派发方核验绑定。
35
+ - `planning` → **阻断**。PRD 和 implement.jsonl 就绪前不允许实现或派发 cowork-implement。
36
+ - `in_progress` / `review` → 放行;按当前阶段推进。
37
+ - `completed` → **阻断**。创建新任务或归档会话。
28
38
 
29
39
  豁免:
30
40
 
31
- - 只读问答
32
- - 纯查询命令(`git status`、`task next`、`task current`)
33
- - 用户明确说"跳过流程"
34
- - `before-dev` 本身
41
+ - 只读问答。
42
+ - 纯查询命令(`git status`、`task next`、`task current`)。
43
+ - 用户明确说"跳过流程"。
44
+ - `task next` / `task validate` 本身。
35
45
 
36
- 不确定是否需要写代码时,先调用 `before-dev`。
46
+ 不确定是否需要写代码时,先运行 `task next`。
37
47
 
38
48
  ## 1. 先思考
39
49