@michaelxwb/code-flow 0.5.2 → 0.6.0

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 (76) hide show
  1. package/docs/migrations/spec-workflow-0.6.0.md +42 -0
  2. package/package.json +4 -2
  3. package/src/adapters/claude/CLAUDE.md +11 -17
  4. package/src/adapters/claude/commands/cf-init.md +5 -4
  5. package/src/adapters/claude/commands/cf-spec.md +50 -0
  6. package/src/adapters/claude/commands/cf-task/align.md +23 -1
  7. package/src/adapters/claude/commands/cf-task/archive.md +7 -0
  8. package/src/adapters/claude/commands/cf-task/plan.md +16 -0
  9. package/src/adapters/claude/commands/cf-task/prd.md +24 -0
  10. package/src/adapters/claude/commands/cf-task/start.md +13 -10
  11. package/src/adapters/claude/settings.local.json +1 -11
  12. package/src/adapters/codex/AGENTS.md +11 -17
  13. package/src/adapters/codex/hooks.json +6 -4
  14. package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
  15. package/src/adapters/codex/skills/cf-spec/SKILL.md +55 -0
  16. package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
  17. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
  18. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
  19. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
  20. package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
  21. package/src/adapters/costrict/CLAUDE.md +11 -17
  22. package/src/adapters/costrict/commands/cf-init.md +5 -4
  23. package/src/adapters/costrict/commands/cf-spec.md +50 -0
  24. package/src/adapters/costrict/commands/cf-task/align.md +23 -1
  25. package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
  26. package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
  27. package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
  28. package/src/adapters/costrict/commands/cf-task/start.md +13 -10
  29. package/src/adapters/costrict/settings.local.json +1 -11
  30. package/src/adapters/opencode/AGENTS.md +11 -17
  31. package/src/adapters/opencode/commands/cf-init.md +4 -3
  32. package/src/adapters/opencode/commands/cf-spec.md +54 -0
  33. package/src/adapters/opencode/commands/cf-task/align.md +24 -1
  34. package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
  35. package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
  36. package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
  37. package/src/adapters/opencode/commands/cf-task/start.md +14 -10
  38. package/src/adapters/opencode/plugins/code-flow/index.js +0 -1
  39. package/src/cli.js +51 -2
  40. package/src/core/code-flow/config.yml +13 -16
  41. package/src/core/code-flow/scripts/cf_checks.py +30 -0
  42. package/src/core/code-flow/scripts/cf_core.py +12 -255
  43. package/src/core/code-flow/scripts/cf_log.py +8 -0
  44. package/src/core/code-flow/scripts/cf_post_hook.py +33 -9
  45. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +65 -0
  46. package/src/core/code-flow/scripts/cf_spec_context.py +1233 -0
  47. package/src/core/code-flow/scripts/cf_spec_gate.py +224 -0
  48. package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
  49. package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
  50. package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
  51. package/src/core/code-flow/scripts/cf_spec_router.py +145 -0
  52. package/src/core/code-flow/scripts/cf_spec_session.py +122 -0
  53. package/src/core/code-flow/scripts/cf_spec_verify.py +293 -0
  54. package/src/core/code-flow/scripts/cf_stats.py +40 -5
  55. package/src/core/code-flow/scripts/cf_stop_hook.py +22 -2
  56. package/src/core/code-flow/scripts/cf_task_runtime.py +142 -0
  57. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +34 -360
  58. package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
  59. package/src/core/code-flow/specs/backend/database.md +13 -1
  60. package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
  61. package/src/core/code-flow/specs/backend/logging.md +13 -1
  62. package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
  63. package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
  64. package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
  65. package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
  66. package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
  67. package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
  68. package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
  69. package/src/core/code-flow/specs/shared/prd-template.md +14 -3
  70. package/src/migrate/spec-workflow.js +238 -0
  71. package/src/adapters/claude/commands/cf-inject.md +0 -55
  72. package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
  73. package/src/adapters/costrict/commands/cf-inject.md +0 -55
  74. package/src/adapters/opencode/commands/cf-inject.md +0 -59
  75. package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
  76. package/src/core/code-flow/scripts/cf_session_hook.py +0 -42
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env python3
2
2
  import fnmatch
3
- import json
4
3
  import os
5
4
  import re
6
5
  import sys
@@ -142,31 +141,15 @@ def build_effective_mapping(project_root: str, mapping: dict) -> dict:
142
141
  return effective
143
142
 
144
143
 
145
- def fallback_domains_for_context(mapping: dict, context_tags: set) -> set:
146
- """Search space for per-spec tag matching when no path pattern fires.
147
-
148
- Returns domains whose name matches context_tags when possible; otherwise
149
- returns every configured domain so that per-spec tag matching downstream
150
- can still cherry-pick. The per-spec tags in config.yml remain the real
151
- filter — this function only widens the search space, never injects.
152
- """
153
- if not mapping:
154
- return set()
155
- by_name = {domain for domain in mapping.keys() if domain.lower() in context_tags}
156
- if by_name:
157
- return by_name
158
- return set(mapping.keys())
159
-
160
-
161
- def is_code_file(rel_path: str, inject_config: dict) -> bool:
144
+ def is_code_file(rel_path: str, runtime_config: dict) -> bool:
162
145
  rel_path = normalize_path(rel_path)
163
- for pattern in inject_config.get("skip_paths") or []:
146
+ for pattern in runtime_config.get("skip_paths") or []:
164
147
  if fnmatch.fnmatch(rel_path, pattern):
165
148
  return False
166
149
  _, ext = os.path.splitext(rel_path)
167
- if ext in (inject_config.get("skip_extensions") or []):
150
+ if ext in (runtime_config.get("skip_extensions") or []):
168
151
  return False
169
- code_exts = inject_config.get("code_extensions") or []
152
+ code_exts = runtime_config.get("code_extensions") or []
170
153
  ext_set = _ext_set_cache.get(id(code_exts))
171
154
  if ext_set is None:
172
155
  ext_set = frozenset(code_exts)
@@ -299,112 +282,6 @@ def extract_context_tags(rel_path: str) -> set:
299
282
  return tags
300
283
 
301
284
 
302
- # --- Fix #5: prompt-text → tag mapping (bilingual aliases) ---
303
- #
304
- # Maps each canonical English tag (as used in .code-flow/config.yml spec tags)
305
- # to a list of substrings we look for in user prompt text. When a user writes
306
- # "写一个用户登录服务,注意性能和异常处理", "性能" → performance and "异常" →
307
- # exception, so the corresponding specs get injected at prompt submission time
308
- # instead of waiting for the Edit/Write tool call.
309
- #
310
- # Kept in Python (not config.yml) because .code-flow/config.yml is category
311
- # 'merge' in cli.js — existing installs wouldn't pick up new aliases on
312
- # upgrade. cf_core.py is category 'tool' so it's overwritten on upgrade.
313
- _TAG_ALIASES = {
314
- "quality": ["质量", "代码质量", "规范"],
315
- "performance": ["性能", "优化", "效率", "perf"],
316
- "error": ["错误", "报错"],
317
- "exception": ["异常", "异常处理"],
318
- "test": ["测试", "单元测试", "unit test"],
319
- "timeout": ["超时"],
320
- "retry": ["重试"],
321
- "cache": ["缓存"],
322
- "log": ["日志", "日志记录", "打印日志"],
323
- "logging": ["日志", "日志记录"],
324
- "database": ["数据库", "db"],
325
- "query": ["查询", "sql"],
326
- "migration": ["迁移"],
327
- "schema": ["数据模型", "模式"],
328
- "api": ["接口", "api"],
329
- "deploy": ["部署", "发布"],
330
- "config": ["配置"],
331
- "component": ["组件"],
332
- "render": ["渲染"],
333
- "ui": ["界面", "ui", "样式"],
334
- "route": ["路由"],
335
- "page": ["页面"],
336
- "state": ["状态"],
337
- "hook": ["hook", "钩子"],
338
- "inject": ["inject", "注入", "注入规范"],
339
- "spec": ["spec", "规范", "约束"],
340
- "scan": ["scan", "扫描", "审计"],
341
- "stats": ["stats", "统计"],
342
- "session": ["session", "会话"],
343
- "init": ["init", "初始化"],
344
- "upgrade": ["upgrade", "升级"],
345
- "merge": ["merge", "合并"],
346
- "platform": ["platform", "平台", "适配器"],
347
- "adapter": ["adapter", "适配器"],
348
- }
349
-
350
- _SHORT_ASCII_ALIAS_THRESHOLD = 3
351
-
352
- def _is_short_ascii(token: str) -> bool:
353
- if len(token) > _SHORT_ASCII_ALIAS_THRESHOLD:
354
- return False
355
- try:
356
- token.encode("ascii")
357
- return True
358
- except UnicodeEncodeError:
359
- return False
360
-
361
-
362
- # Precompute (needle, is_short_ascii, compiled_pattern_or_None) at import time
363
- # so extract_prompt_tags() is a pure lookup loop with zero per-call overhead.
364
- _PREPARED_ALIASES: list = []
365
-
366
-
367
- def _init_prepared_aliases() -> None:
368
- for canonical, aliases in _TAG_ALIASES.items():
369
- entries = []
370
- for alias in [canonical, *aliases]:
371
- needle = alias.lower()
372
- if _is_short_ascii(needle):
373
- pattern = re.compile(r"\b" + re.escape(needle) + r"\b", re.IGNORECASE)
374
- entries.append((needle, True, pattern))
375
- else:
376
- entries.append((needle, False, None))
377
- _PREPARED_ALIASES.append((canonical, entries))
378
-
379
-
380
- _init_prepared_aliases()
381
-
382
-
383
- def extract_prompt_tags(prompt_text) -> set:
384
- """Scan prompt text for alias hits and return canonical tags.
385
-
386
- - Lowercases ASCII (Chinese is unaffected by .lower()).
387
- - Short ASCII aliases matched with word-boundary regex (precompiled at import).
388
- - Chinese aliases and long English aliases use plain substring match.
389
- - Silent on empty/non-string input.
390
- """
391
- if not isinstance(prompt_text, str) or not prompt_text.strip():
392
- return set()
393
- lower = prompt_text.lower()
394
- hits: set = set()
395
- for canonical, entries in _PREPARED_ALIASES:
396
- for needle, is_short, pattern in entries:
397
- if is_short:
398
- if pattern.search(lower): # type: ignore[union-attr]
399
- hits.add(canonical)
400
- break
401
- else:
402
- if needle in lower:
403
- hits.add(canonical)
404
- break
405
- return hits
406
-
407
-
408
285
  def normalize_spec_entry(entry) -> dict:
409
286
  """Normalize spec config entry. Supports both old (string) and new (dict) format."""
410
287
  if isinstance(entry, str):
@@ -418,33 +295,6 @@ def normalize_spec_entry(entry) -> dict:
418
295
  return {}
419
296
 
420
297
 
421
- def match_specs_by_tags(
422
- specs_config: list, context_tags: set, prompt_tags: set = None
423
- ) -> tuple:
424
- """Return (matched_specs, has_tier1_match).
425
-
426
- Wildcard tag '*' always matches (used by tier 0 specs like _map.md).
427
- prompt_tags (if provided) are unioned with context_tags so that Chinese/
428
- English keywords from the user prompt can also gate tier 1 specs.
429
- The caller uses has_tier1_match to decide whether to fallback to all specs.
430
- """
431
- effective_tags = context_tags if prompt_tags is None else (context_tags | prompt_tags)
432
- matched = []
433
- has_tier1_match = False
434
- for entry in specs_config:
435
- cfg = normalize_spec_entry(entry)
436
- if not cfg.get("path"):
437
- continue
438
- spec_tags = set(cfg.get("tags") or [])
439
- if "*" in spec_tags:
440
- matched.append(cfg)
441
- elif spec_tags & effective_tags:
442
- matched.append(cfg)
443
- if cfg.get("tier", 1) != 0:
444
- has_tier1_match = True
445
- return matched, has_tier1_match
446
-
447
-
448
298
  _FILENAME_WORDS_RE = re.compile(r"[a-z]+")
449
299
  _BULLET_PREFIXES = ("- ", "* ", "+ ")
450
300
  _COMPRESS_HTML_RE = re.compile(r"<!--.*?-->", re.DOTALL)
@@ -503,8 +353,8 @@ def read_matched_specs(
503
353
  """Read matched spec files; optionally apply lossless compression.
504
354
 
505
355
  When ``compress`` is True (default), each spec's content is passed through
506
- :func:`compress_content` before token estimation so ``select_specs_tiered``
507
- budget decisions benefit from compression savings.
356
+ :func:`compress_content` before token estimation so callers can make
357
+ explicit budget decisions from the returned token counts.
508
358
 
509
359
  Returned items carry:
510
360
  - ``content``: final content (compressed when ``compress=True``)
@@ -595,74 +445,6 @@ def read_specs(project_root: str, domain: str, domain_cfg: dict) -> list:
595
445
  return specs
596
446
 
597
447
 
598
- def select_specs(specs: list, budget: int, priorities: dict) -> list:
599
- """Legacy select by priority. Used by cf-inject manual command."""
600
- if budget <= 0:
601
- return []
602
-
603
- def priority(spec: dict) -> int:
604
- value = priorities.get(spec.get("path"))
605
- if isinstance(value, int):
606
- return value
607
- try:
608
- return int(value)
609
- except Exception:
610
- return 1000
611
-
612
- ordered = sorted(specs, key=lambda spec: (priority(spec), spec.get("path", "")))
613
- selected = []
614
- total = 0
615
- for spec in ordered:
616
- if total + spec.get("tokens", 0) <= budget:
617
- selected.append(spec)
618
- total += spec.get("tokens", 0)
619
- return selected
620
-
621
-
622
- def select_specs_tiered(specs: list, budget: int, map_max: int = 400) -> list:
623
- """Tier-aware spec selection.
624
-
625
- Tier 0: included if within map_max budget (fix #4).
626
- Tier 1: budget-controlled, ordered by list position (preserved).
627
- """
628
- tier0 = [s for s in specs if s.get("tier", 1) == 0]
629
- tier1 = [s for s in specs if s.get("tier", 1) != 0]
630
-
631
- selected = []
632
- for spec in tier0:
633
- if spec.get("tokens", 0) <= map_max:
634
- selected.append(spec)
635
- else:
636
- _log(
637
- f"WARNING: {spec['path']} exceeds map_max budget "
638
- f"({spec['tokens']} > {map_max} tokens), skipped"
639
- )
640
-
641
- total = 0
642
- for spec in tier1:
643
- tokens = spec.get("tokens", 0)
644
- if total + tokens <= budget:
645
- selected.append(spec)
646
- total += tokens
647
- return selected
648
-
649
-
650
- def resolve_inject_mode(inject_config: dict) -> str:
651
- """Resolve inject.mode: only the literal string "catalog" enables catalog mode.
652
-
653
- Missing / None / any other value → "full" (pre-0.5 behavior), so existing
654
- user configs keep byte-identical injection after upgrade; new `code-flow
655
- init` deployments opt in via the config template. Mirrors the
656
- resolve_compress philosophy: only an explicit literal flips behavior.
657
- """
658
- if not isinstance(inject_config, dict):
659
- return "full"
660
- mode = inject_config.get("mode")
661
- if isinstance(mode, str) and mode.strip().lower() == "catalog":
662
- return "catalog"
663
- return "full"
664
-
665
-
666
448
  def non_injectable_specs(effective_mapping: dict) -> set:
667
449
  """tags: [] 的命令专用模板(如 shared 的 PRD/设计模板)——永不自动注入,
668
450
  预算统计(cf-stats/cf-scan)必须排除,避免利用率虚高。"""
@@ -776,7 +558,7 @@ def _catalog_entries(project_root: str, effective_mapping: dict) -> list:
776
558
  def build_spec_catalog(
777
559
  project_root: str, effective_mapping: dict, catalog_max: int = 200
778
560
  ) -> str:
779
- """Build the compact one-line-per-spec catalog for inject.mode=catalog.
561
+ """Build the compact Catalog used only when no task or path is active.
780
562
 
781
563
  The model performs the semantic matching itself by reading the catalog and
782
564
  pulling relevant specs — replacing keyword-table guessing on prompts with
@@ -855,27 +637,6 @@ def assemble_context(specs: list, heading: str) -> str:
855
637
  return "\n\n".join(parts)
856
638
 
857
639
 
858
- def load_inject_state(project_root: str) -> dict:
859
- state_path = os.path.join(project_root, ".code-flow", ".inject-state")
860
- try:
861
- with open(state_path, "r", encoding="utf-8") as file:
862
- data = json.load(file)
863
- if isinstance(data, dict):
864
- return data
865
- except Exception:
866
- return {}
867
- return {}
868
-
869
-
870
- def save_inject_state(project_root: str, payload: dict) -> None:
871
- state_path = os.path.join(project_root, ".code-flow", ".inject-state")
872
- try:
873
- with open(state_path, "w", encoding="utf-8") as file:
874
- json.dump(payload, file)
875
- except Exception:
876
- return
877
-
878
-
879
640
  def _log(msg: str) -> None:
880
641
  """Log to stderr (fix #9: don't pollute stdout which is hook output)."""
881
642
  print(msg, file=sys.stderr)
@@ -900,16 +661,16 @@ def ensure_utf8_io() -> None:
900
661
  pass
901
662
 
902
663
 
903
- def resolve_compress(inject_config: dict) -> bool:
664
+ def resolve_compress(quality_config: dict) -> bool:
904
665
  """Return whether Hook-time spec compression is enabled.
905
666
 
906
667
  Only a literal ``False`` turns it off; missing, ``None``, or any
907
668
  non-bool value falls back to ``True`` so upgrades pick up compression
908
669
  without requiring a config edit.
909
670
  """
910
- if not isinstance(inject_config, dict):
671
+ if not isinstance(quality_config, dict):
911
672
  return True
912
- value = inject_config.get("compress")
673
+ value = quality_config.get("compress")
913
674
  if value is False:
914
675
  return False
915
676
  return True
@@ -918,12 +679,8 @@ def resolve_compress(inject_config: dict) -> bool:
918
679
  def resolve_session_id(hook_data: dict) -> str:
919
680
  """Derive a session id consistent across PreToolUse / UserPromptSubmit hooks.
920
681
 
921
- Prefer the session_id Claude Code / Codex pass in the hook payload so all
922
- hooks within one session share the same id (fix #10 — previously
923
- cf_inject_hook used PID while cf_codex_user_prompt_hook used the hook-
924
- provided id, causing .inject-state dedup to break and specs to be
925
- re-injected). Falls back to the current PID when the hook runtime omits
926
- the field (older Codex versions, unit tests).
682
+ Prefer the platform-provided id so prompt, edit, feedback, and stop events
683
+ share one trace. Fall back to the PID for older runtimes and unit tests.
927
684
  """
928
685
  if isinstance(hook_data, dict):
929
686
  sid = hook_data.get("session_id")
@@ -27,6 +27,14 @@ KNOWN_EVENTS = (
27
27
  "false_positive",
28
28
  "degrade",
29
29
  "stop_check",
30
+ "spec_candidate",
31
+ "spec_bound",
32
+ "spec_applied",
33
+ "spec_read",
34
+ "spec_drift",
35
+ "spec_gate",
36
+ "late_violation",
37
+ "metrics_degraded",
30
38
  )
31
39
 
32
40
 
@@ -21,6 +21,8 @@ import os
21
21
  import sys
22
22
 
23
23
  import cf_log
24
+ from cf_spec_context import ContextError, load_active_task
25
+ from cf_task_runtime import evaluate_scope, run_done_gate
24
26
  from cf_checks import (
25
27
  load_check_state,
26
28
  load_spec_checks,
@@ -32,8 +34,6 @@ from cf_core import (
32
34
  _log,
33
35
  build_effective_mapping,
34
36
  ensure_utf8_io,
35
- extract_context_tags,
36
- fallback_domains_for_context,
37
37
  is_code_file,
38
38
  load_config,
39
39
  match_domains,
@@ -93,6 +93,23 @@ def _feedback_text(violations: list) -> str:
93
93
  return "\n".join(lines)
94
94
 
95
95
 
96
+ def _active_feedback(project_root: str) -> str:
97
+ marker = os.path.join(project_root, ".code-flow", ".active-task.json")
98
+ if not os.path.exists(marker):
99
+ return ""
100
+ active = load_active_task(project_root)
101
+ task_dir = os.path.join(project_root, active.task_dir)
102
+ scope = evaluate_scope(project_root, task_dir)
103
+ if scope.decision == "pause":
104
+ return scope.message
105
+ result = run_done_gate(project_root, task_dir)
106
+ failed = [item for item in result.evidence if item.get("status") != "verified"]
107
+ if not failed:
108
+ return ""
109
+ refs = ", ".join(str(item.get("verifier_ref")) for item in failed)
110
+ return f"当前 TASK required verifier 未通过:{refs}。修复后再继续。"
111
+
112
+
96
113
  def main() -> None:
97
114
  try:
98
115
  ensure_utf8_io()
@@ -108,6 +125,17 @@ def main() -> None:
108
125
  return
109
126
 
110
127
  project_root = os.getcwd()
128
+ try:
129
+ active_feedback = _active_feedback(project_root)
130
+ except (ContextError, OSError, ValueError) as exc:
131
+ message = f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"
132
+ payload = {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": message}}
133
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False))
134
+ return
135
+ if active_feedback:
136
+ payload = {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": active_feedback}}
137
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False))
138
+ return
111
139
  config = load_config(project_root)
112
140
  if not config:
113
141
  return
@@ -119,18 +147,14 @@ def main() -> None:
119
147
  if not os.path.isabs(abs_path):
120
148
  abs_path = os.path.join(project_root, file_path)
121
149
  rel_path = normalize_path(os.path.relpath(abs_path, project_root))
122
- if not is_code_file(rel_path, config.get("inject") or {}):
123
- return
124
-
125
150
  sid = resolve_session_id(data)
151
+ cf_log.append_event(project_root, "edit", {"file": rel_path, "tool": tool_name}, sid)
152
+ if not is_code_file(rel_path, config.get("quality_loop") or {}):
153
+ return
126
154
  mapping = build_effective_mapping(
127
155
  project_root, config.get("path_mapping") or {}
128
156
  )
129
157
  domains = match_domains(rel_path, mapping)
130
- if not domains:
131
- domains = sorted(fallback_domains_for_context(
132
- mapping, extract_context_tags(rel_path)
133
- ))
134
158
  if not domains:
135
159
  return
136
160
 
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: enforce active Context or inject deterministic path Specs."""
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ import cf_log
10
+ from cf_core import _log, ensure_utf8_io, normalize_path, resolve_session_id
11
+ from cf_spec_context import load_active_task, load_context
12
+ from cf_spec_resolver import resolve_candidates
13
+ from cf_spec_router import RouterError, route_prompt
14
+
15
+
16
+ def _active_expansion(root: str, relative: str) -> tuple[str, ...]:
17
+ marker = Path(root) / ".code-flow/.active-task.json"
18
+ if not marker.exists():
19
+ return ()
20
+ active = load_active_task(root)
21
+ context = load_context(str(Path(root) / active.task_dir / "spec-context.yml"))
22
+ bound = {item.spec_id for item in context.bindings}
23
+ return tuple(
24
+ item.spec_id for item in resolve_candidates(root, "code", (relative,))
25
+ if item.spec_id not in bound and item.metadata.enforcement == "required"
26
+ )
27
+
28
+
29
+ def _deny(reason: str) -> dict[str, object]:
30
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}
31
+
32
+
33
+ def main() -> None:
34
+ try:
35
+ ensure_utf8_io()
36
+ raw = sys.stdin.read()
37
+ if not raw.strip():
38
+ return
39
+ data = json.loads(raw)
40
+ tool_name = data.get("tool_name", "")
41
+ file_path = (data.get("tool_input") or {}).get("file_path", "")
42
+ if tool_name not in {"Edit", "Write", "MultiEdit"} or not isinstance(file_path, str) or not file_path:
43
+ return
44
+ root = os.getcwd()
45
+ absolute = file_path if os.path.isabs(file_path) else os.path.join(root, file_path)
46
+ relative = normalize_path(os.path.relpath(absolute, root))
47
+ expanded = _active_expansion(root, relative)
48
+ if expanded:
49
+ reason = f"SPEC_WORKFLOW_BLOCKED: path introduces required Specs {', '.join(expanded)}; refresh Context and Plan first."
50
+ sys.stdout.write(json.dumps(_deny(reason), ensure_ascii=False))
51
+ return
52
+ sid = resolve_session_id(data)
53
+ result = route_prompt(root, (relative,), sid)
54
+ if result.text:
55
+ payload = {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": result.text}}
56
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False))
57
+ cf_log.append_event(root, "edit_intent", {"file": relative, "tool": tool_name, "specs": list(result.specs)}, sid)
58
+ except RouterError as exc:
59
+ sys.stdout.write(json.dumps(_deny(f"SPEC_WORKFLOW_BLOCKED [{exc.code}]: {exc}"), ensure_ascii=False))
60
+ except (json.JSONDecodeError, OSError, ValueError) as exc:
61
+ _log(f"cf_pre_tool_hook error: {exc}")
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()