@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.
- package/docs/migrations/spec-workflow-0.6.0.md +42 -0
- package/package.json +4 -2
- package/src/adapters/claude/CLAUDE.md +11 -17
- package/src/adapters/claude/commands/cf-init.md +5 -4
- package/src/adapters/claude/commands/cf-spec.md +50 -0
- package/src/adapters/claude/commands/cf-task/align.md +23 -1
- package/src/adapters/claude/commands/cf-task/archive.md +7 -0
- package/src/adapters/claude/commands/cf-task/plan.md +16 -0
- package/src/adapters/claude/commands/cf-task/prd.md +24 -0
- package/src/adapters/claude/commands/cf-task/start.md +13 -10
- package/src/adapters/claude/settings.local.json +1 -11
- package/src/adapters/codex/AGENTS.md +11 -17
- package/src/adapters/codex/hooks.json +6 -4
- package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +55 -0
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
- package/src/adapters/costrict/CLAUDE.md +11 -17
- package/src/adapters/costrict/commands/cf-init.md +5 -4
- package/src/adapters/costrict/commands/cf-spec.md +50 -0
- package/src/adapters/costrict/commands/cf-task/align.md +23 -1
- package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
- package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
- package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
- package/src/adapters/costrict/commands/cf-task/start.md +13 -10
- package/src/adapters/costrict/settings.local.json +1 -11
- package/src/adapters/opencode/AGENTS.md +11 -17
- package/src/adapters/opencode/commands/cf-init.md +4 -3
- package/src/adapters/opencode/commands/cf-spec.md +54 -0
- package/src/adapters/opencode/commands/cf-task/align.md +24 -1
- package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
- package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
- package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
- package/src/adapters/opencode/commands/cf-task/start.md +14 -10
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -1
- package/src/cli.js +51 -2
- package/src/core/code-flow/config.yml +13 -16
- package/src/core/code-flow/scripts/cf_checks.py +30 -0
- package/src/core/code-flow/scripts/cf_core.py +12 -255
- package/src/core/code-flow/scripts/cf_log.py +8 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +33 -9
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +65 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +1233 -0
- package/src/core/code-flow/scripts/cf_spec_gate.py +224 -0
- package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
- package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
- package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
- package/src/core/code-flow/scripts/cf_spec_router.py +145 -0
- package/src/core/code-flow/scripts/cf_spec_session.py +122 -0
- package/src/core/code-flow/scripts/cf_spec_verify.py +293 -0
- package/src/core/code-flow/scripts/cf_stats.py +40 -5
- package/src/core/code-flow/scripts/cf_stop_hook.py +22 -2
- package/src/core/code-flow/scripts/cf_task_runtime.py +142 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +34 -360
- package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
- package/src/core/code-flow/specs/backend/database.md +13 -1
- package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/backend/logging.md +13 -1
- package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
- package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
- package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
- package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
- package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
- package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
- package/src/core/code-flow/specs/shared/prd-template.md +14 -3
- package/src/migrate/spec-workflow.js +238 -0
- package/src/adapters/claude/commands/cf-inject.md +0 -55
- package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
- package/src/adapters/costrict/commands/cf-inject.md +0 -55
- package/src/adapters/opencode/commands/cf-inject.md +0 -59
- package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
- 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
|
|
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
|
|
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 (
|
|
150
|
+
if ext in (runtime_config.get("skip_extensions") or []):
|
|
168
151
|
return False
|
|
169
|
-
code_exts =
|
|
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
|
|
507
|
-
budget decisions
|
|
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
|
|
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(
|
|
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(
|
|
671
|
+
if not isinstance(quality_config, dict):
|
|
911
672
|
return True
|
|
912
|
-
value =
|
|
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
|
|
922
|
-
|
|
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")
|
|
@@ -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()
|