@michaelxwb/code-flow 0.5.2 → 0.6.1

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 (82) 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 +63 -0
  6. package/src/adapters/claude/commands/cf-sync.md +25 -0
  7. package/src/adapters/claude/commands/cf-task/align.md +23 -1
  8. package/src/adapters/claude/commands/cf-task/archive.md +7 -0
  9. package/src/adapters/claude/commands/cf-task/plan.md +16 -0
  10. package/src/adapters/claude/commands/cf-task/prd.md +24 -0
  11. package/src/adapters/claude/commands/cf-task/start.md +13 -10
  12. package/src/adapters/claude/settings.local.json +1 -11
  13. package/src/adapters/codex/AGENTS.md +11 -17
  14. package/src/adapters/codex/hooks.json +6 -4
  15. package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
  16. package/src/adapters/codex/skills/cf-spec/SKILL.md +56 -0
  17. package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
  18. package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
  19. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
  20. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
  21. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
  22. package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
  23. package/src/adapters/costrict/CLAUDE.md +11 -17
  24. package/src/adapters/costrict/commands/cf-init.md +5 -4
  25. package/src/adapters/costrict/commands/cf-spec.md +63 -0
  26. package/src/adapters/costrict/commands/cf-sync.md +25 -0
  27. package/src/adapters/costrict/commands/cf-task/align.md +23 -1
  28. package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
  29. package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
  30. package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
  31. package/src/adapters/costrict/commands/cf-task/start.md +13 -10
  32. package/src/adapters/costrict/settings.local.json +1 -11
  33. package/src/adapters/opencode/AGENTS.md +11 -17
  34. package/src/adapters/opencode/commands/cf-init.md +4 -3
  35. package/src/adapters/opencode/commands/cf-spec.md +55 -0
  36. package/src/adapters/opencode/commands/cf-sync.md +29 -0
  37. package/src/adapters/opencode/commands/cf-task/align.md +24 -1
  38. package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
  39. package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
  40. package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
  41. package/src/adapters/opencode/commands/cf-task/start.md +14 -10
  42. package/src/adapters/opencode/plugins/code-flow/index.js +3 -4
  43. package/src/cli.js +51 -2
  44. package/src/core/code-flow/config.yml +14 -16
  45. package/src/core/code-flow/scripts/cf_checks.py +30 -0
  46. package/src/core/code-flow/scripts/cf_core.py +36 -256
  47. package/src/core/code-flow/scripts/cf_log.py +8 -0
  48. package/src/core/code-flow/scripts/cf_post_hook.py +6 -9
  49. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +88 -0
  50. package/src/core/code-flow/scripts/cf_session_state.py +37 -0
  51. package/src/core/code-flow/scripts/cf_spec_context.py +1470 -0
  52. package/src/core/code-flow/scripts/cf_spec_gate.py +230 -0
  53. package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
  54. package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
  55. package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
  56. package/src/core/code-flow/scripts/cf_spec_router.py +149 -0
  57. package/src/core/code-flow/scripts/cf_spec_session.py +118 -0
  58. package/src/core/code-flow/scripts/cf_spec_verify.py +340 -0
  59. package/src/core/code-flow/scripts/cf_stats.py +40 -5
  60. package/src/core/code-flow/scripts/cf_stop_hook.py +43 -2
  61. package/src/core/code-flow/scripts/cf_sync.py +140 -0
  62. package/src/core/code-flow/scripts/cf_task_runtime.py +184 -0
  63. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +115 -345
  64. package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
  65. package/src/core/code-flow/specs/backend/database.md +13 -1
  66. package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
  67. package/src/core/code-flow/specs/backend/logging.md +13 -1
  68. package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
  69. package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
  70. package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
  71. package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
  72. package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
  73. package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
  74. package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
  75. package/src/core/code-flow/specs/shared/prd-template.md +14 -3
  76. package/src/migrate/spec-workflow.js +238 -0
  77. package/src/adapters/claude/commands/cf-inject.md +0 -55
  78. package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
  79. package/src/adapters/costrict/commands/cf-inject.md +0 -55
  80. package/src/adapters/opencode/commands/cf-inject.md +0 -59
  81. package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
  82. 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)必须排除,避免利用率虚高。"""
@@ -685,7 +467,8 @@ def resolve_quality_loop(config: dict) -> dict:
685
467
  """
686
468
  if not isinstance(config, dict):
687
469
  return {"enabled": False, "post_check": False,
688
- "stop_check": False, "correction_capture": False}
470
+ "stop_check": False, "correction_capture": False,
471
+ "compress_reminder": False}
689
472
  cfg = config.get("quality_loop")
690
473
  cfg = cfg if isinstance(cfg, dict) else {}
691
474
  enabled = cfg.get("enabled") is True
@@ -698,9 +481,31 @@ def resolve_quality_loop(config: dict) -> dict:
698
481
  "post_check": _sub("post_check"),
699
482
  "stop_check": _sub("stop_check"),
700
483
  "correction_capture": _sub("correction_capture"),
484
+ "compress_reminder": _sub("compress_reminder"),
701
485
  }
702
486
 
703
487
 
488
+ ENFORCEMENT_MODES = frozenset(("required", "warn", "inject"))
489
+
490
+
491
+ def resolve_enforcement(config: dict) -> str:
492
+ """Project-wide Spec Workflow enforcement tier (light-mode ladder).
493
+
494
+ required — full fail-closed gates (default; upgraded projects keep it);
495
+ warn — gates run but only log/report, never block the session;
496
+ inject — routing/injection only; gates and blocks are disabled.
497
+ Missing or invalid values fail closed to "required".
498
+ """
499
+ if not isinstance(config, dict):
500
+ return "required"
501
+ workflow = config.get("spec_workflow")
502
+ workflow = workflow if isinstance(workflow, dict) else {}
503
+ value = workflow.get("enforcement")
504
+ if isinstance(value, str) and value in ENFORCEMENT_MODES:
505
+ return value
506
+ return "required"
507
+
508
+
704
509
  _FRONTMATTER_RE = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*\n?", re.DOTALL)
705
510
 
706
511
 
@@ -776,7 +581,7 @@ def _catalog_entries(project_root: str, effective_mapping: dict) -> list:
776
581
  def build_spec_catalog(
777
582
  project_root: str, effective_mapping: dict, catalog_max: int = 200
778
583
  ) -> str:
779
- """Build the compact one-line-per-spec catalog for inject.mode=catalog.
584
+ """Build the compact Catalog used only when no task or path is active.
780
585
 
781
586
  The model performs the semantic matching itself by reading the catalog and
782
587
  pulling relevant specs — replacing keyword-table guessing on prompts with
@@ -855,27 +660,6 @@ def assemble_context(specs: list, heading: str) -> str:
855
660
  return "\n\n".join(parts)
856
661
 
857
662
 
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
663
  def _log(msg: str) -> None:
880
664
  """Log to stderr (fix #9: don't pollute stdout which is hook output)."""
881
665
  print(msg, file=sys.stderr)
@@ -900,16 +684,16 @@ def ensure_utf8_io() -> None:
900
684
  pass
901
685
 
902
686
 
903
- def resolve_compress(inject_config: dict) -> bool:
687
+ def resolve_compress(quality_config: dict) -> bool:
904
688
  """Return whether Hook-time spec compression is enabled.
905
689
 
906
690
  Only a literal ``False`` turns it off; missing, ``None``, or any
907
691
  non-bool value falls back to ``True`` so upgrades pick up compression
908
692
  without requiring a config edit.
909
693
  """
910
- if not isinstance(inject_config, dict):
694
+ if not isinstance(quality_config, dict):
911
695
  return True
912
- value = inject_config.get("compress")
696
+ value = quality_config.get("compress")
913
697
  if value is False:
914
698
  return False
915
699
  return True
@@ -918,12 +702,8 @@ def resolve_compress(inject_config: dict) -> bool:
918
702
  def resolve_session_id(hook_data: dict) -> str:
919
703
  """Derive a session id consistent across PreToolUse / UserPromptSubmit hooks.
920
704
 
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).
705
+ Prefer the platform-provided id so prompt, edit, feedback, and stop events
706
+ share one trace. Fall back to the PID for older runtimes and unit tests.
927
707
  """
928
708
  if isinstance(hook_data, dict):
929
709
  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
 
@@ -32,13 +32,12 @@ from cf_core import (
32
32
  _log,
33
33
  build_effective_mapping,
34
34
  ensure_utf8_io,
35
- extract_context_tags,
36
- fallback_domains_for_context,
37
35
  is_code_file,
38
36
  load_config,
39
37
  match_domains,
40
38
  normalize_path,
41
39
  normalize_spec_entry,
40
+ resolve_enforcement,
42
41
  resolve_quality_loop,
43
42
  resolve_session_id,
44
43
  )
@@ -111,6 +110,8 @@ def main() -> None:
111
110
  config = load_config(project_root)
112
111
  if not config:
113
112
  return
113
+ if resolve_enforcement(config) == "inject":
114
+ return
114
115
  quality_loop = resolve_quality_loop(config)
115
116
  if not quality_loop["post_check"]:
116
117
  return
@@ -119,18 +120,14 @@ def main() -> None:
119
120
  if not os.path.isabs(abs_path):
120
121
  abs_path = os.path.join(project_root, file_path)
121
122
  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
123
  sid = resolve_session_id(data)
124
+ cf_log.append_event(project_root, "edit", {"file": rel_path, "tool": tool_name}, sid)
125
+ if not is_code_file(rel_path, config.get("quality_loop") or {}):
126
+ return
126
127
  mapping = build_effective_mapping(
127
128
  project_root, config.get("path_mapping") or {}
128
129
  )
129
130
  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
131
  if not domains:
135
132
  return
136
133
 
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: advisory Context/scope guidance on Edit/Write/MultiEdit.
3
+
4
+ Hot-path policy: never deny the tool; inject context or guidance instead.
5
+ Hard enforcement lives at the checkpoints (start / done / stop), not here.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import sys
12
+
13
+ import cf_log
14
+ from cf_core import _log, ensure_utf8_io, load_config, normalize_path, resolve_enforcement, resolve_session_id
15
+ from cf_session_state import load_session_state, save_session_state
16
+ from cf_spec_context import load_active_task, load_context
17
+ from cf_spec_resolver import resolve_candidates
18
+ from cf_spec_router import RouterError, route_prompt
19
+
20
+ _WARN_TEXT = "⚠ Spec Workflow 校验未通过(warn 模式):{message} — 建议运行 cf-spec doctor 检查。"
21
+
22
+
23
+ def _active_expansion(root: str, relative: str) -> tuple[str, ...]:
24
+ marker = Path(root) / ".code-flow/.active-task.json"
25
+ if not marker.exists():
26
+ return ()
27
+ active = load_active_task(root)
28
+ context = load_context(str(Path(root) / active.task_dir / "spec-context.yml"))
29
+ bound = {item.spec_id for item in context.bindings}
30
+ return tuple(
31
+ item.spec_id for item in resolve_candidates(root, "code", (relative,))
32
+ if item.spec_id not in bound and item.metadata.enforcement == "required"
33
+ )
34
+
35
+
36
+ def _inject(text: str) -> dict[str, object]:
37
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": text}}
38
+
39
+
40
+ def main() -> None:
41
+ try:
42
+ ensure_utf8_io()
43
+ raw = sys.stdin.read()
44
+ if not raw.strip():
45
+ return
46
+ data = json.loads(raw)
47
+ tool_name = data.get("tool_name", "")
48
+ file_path = (data.get("tool_input") or {}).get("file_path", "")
49
+ if tool_name not in {"Edit", "Write", "MultiEdit"} or not isinstance(file_path, str) or not file_path:
50
+ return
51
+ root = os.getcwd()
52
+ sid = resolve_session_id(data)
53
+ config = load_config(root)
54
+ enforcement = resolve_enforcement(config) if config else "required"
55
+ absolute = file_path if os.path.isabs(file_path) else os.path.join(root, file_path)
56
+ relative = normalize_path(os.path.relpath(absolute, root))
57
+ try:
58
+ expanded = _active_expansion(root, relative)
59
+ result = route_prompt(root, (relative,), sid)
60
+ except RouterError as exc:
61
+ if enforcement == "required":
62
+ sys.stdout.write(json.dumps(_inject(f"SPEC_WORKFLOW_BLOCKED [{exc.code}]: {exc}. Run cf-spec doctor before continuing."), ensure_ascii=False))
63
+ elif enforcement == "warn":
64
+ sys.stdout.write(json.dumps(_inject(_WARN_TEXT.format(message=str(exc))), ensure_ascii=False))
65
+ return
66
+ if expanded:
67
+ message = f"⚠ 该路径引入未绑定 required Spec:{', '.join(expanded)} — 建议先 refresh Context / Plan 再继续编辑。"
68
+ sys.stdout.write(json.dumps(_inject(message), ensure_ascii=False))
69
+ return
70
+ text = result.text
71
+ if result.mode == "task" and result.context_sha256:
72
+ # The projection is already in context from the prompt route unless
73
+ # the bound rules changed; skip per-edit re-injection.
74
+ state = load_session_state(root)
75
+ if state.get("injected_sha256") == result.context_sha256:
76
+ text = ""
77
+ else:
78
+ state["injected_sha256"] = result.context_sha256
79
+ save_session_state(root, state)
80
+ if text:
81
+ sys.stdout.write(json.dumps(_inject(text), ensure_ascii=False))
82
+ cf_log.append_event(root, "edit_intent", {"file": relative, "tool": tool_name, "specs": list(result.specs)}, sid)
83
+ except (json.JSONDecodeError, OSError, ValueError) as exc:
84
+ _log(f"cf_pre_tool_hook error: {exc}")
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ """Shared per-session state for injection/reminder cadence.
3
+
4
+ Stores one JSON file per project root (`.code-flow/.session-state.json`),
5
+ keyed by session id. Both read and write are best-effort: a corrupt or
6
+ unwritable state degrades to no-op rather than breaking hook protocols.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Mapping
13
+
14
+
15
+ _STATE_PATH = ".code-flow/.session-state.json"
16
+
17
+
18
+ def load_session_state(root: str) -> dict:
19
+ """Return the persisted state dict; {} on any failure (never raises)."""
20
+ try:
21
+ value = json.loads((Path(root) / _STATE_PATH).read_text(encoding="utf-8"))
22
+ except (OSError, json.JSONDecodeError, UnicodeError):
23
+ return {}
24
+ return value if isinstance(value, dict) else {}
25
+
26
+
27
+ def save_session_state(root: str, state: Mapping) -> bool:
28
+ """Atomically persist state; False on failure (never raises)."""
29
+ path = Path(root) / _STATE_PATH
30
+ try:
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
33
+ temporary.write_text(json.dumps(dict(state), ensure_ascii=False), encoding="utf-8")
34
+ os.replace(temporary, path)
35
+ return True
36
+ except (OSError, UnicodeError, TypeError):
37
+ return False