@michaelxwb/code-flow 0.6.0 → 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.
- package/package.json +1 -1
- package/src/adapters/claude/commands/cf-spec.md +13 -0
- package/src/adapters/claude/commands/cf-sync.md +25 -0
- package/src/adapters/codex/skills/cf-spec/SKILL.md +1 -0
- package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
- package/src/adapters/costrict/commands/cf-spec.md +13 -0
- package/src/adapters/costrict/commands/cf-sync.md +25 -0
- package/src/adapters/opencode/commands/cf-spec.md +1 -0
- package/src/adapters/opencode/commands/cf-sync.md +29 -0
- package/src/adapters/opencode/plugins/code-flow/index.js +3 -3
- package/src/core/code-flow/config.yml +2 -1
- package/src/core/code-flow/scripts/cf_core.py +24 -1
- package/src/core/code-flow/scripts/cf_post_hook.py +3 -30
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +37 -14
- package/src/core/code-flow/scripts/cf_session_state.py +37 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +250 -13
- package/src/core/code-flow/scripts/cf_spec_gate.py +12 -6
- package/src/core/code-flow/scripts/cf_spec_router.py +6 -2
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -9
- package/src/core/code-flow/scripts/cf_spec_verify.py +51 -4
- package/src/core/code-flow/scripts/cf_stop_hook.py +33 -12
- package/src/core/code-flow/scripts/cf_sync.py +140 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +49 -7
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +104 -8
|
@@ -25,11 +25,13 @@ from cf_core import (
|
|
|
25
25
|
ensure_utf8_io,
|
|
26
26
|
load_config,
|
|
27
27
|
normalize_path,
|
|
28
|
+
resolve_enforcement,
|
|
28
29
|
resolve_quality_loop,
|
|
29
30
|
resolve_session_id,
|
|
30
31
|
)
|
|
31
32
|
|
|
32
33
|
TOTAL_BUDGET_SECONDS = 30.0
|
|
34
|
+
GATE_BUDGET_SECONDS = 25.0
|
|
33
35
|
_BRACE_RE = re.compile(r"\{([^{}]+)\}")
|
|
34
36
|
_TASK_SECTION_RE = re.compile(
|
|
35
37
|
r"(?ms)^## (TASK-\d+):.*?(?=^## TASK-\d+:|\Z)"
|
|
@@ -246,6 +248,12 @@ def main() -> None:
|
|
|
246
248
|
return # 已因本 hook 续跑过一轮,避免循环
|
|
247
249
|
project_root = os.getcwd()
|
|
248
250
|
sid = resolve_session_id(data)
|
|
251
|
+
config = load_config(project_root)
|
|
252
|
+
if not config:
|
|
253
|
+
return
|
|
254
|
+
enforcement = resolve_enforcement(config)
|
|
255
|
+
if enforcement == "inject":
|
|
256
|
+
return # 轻量模式:只注入不门禁,停止会话不受限
|
|
249
257
|
marker = os.path.join(project_root, ".code-flow", ".active-task.json")
|
|
250
258
|
has_active = os.path.exists(marker)
|
|
251
259
|
files = []
|
|
@@ -253,19 +261,25 @@ def main() -> None:
|
|
|
253
261
|
try:
|
|
254
262
|
active = load_active_task(project_root)
|
|
255
263
|
task_dir = os.path.join(project_root, active.task_dir)
|
|
256
|
-
done = run_done_gate(project_root, task_dir)
|
|
264
|
+
done = run_done_gate(project_root, task_dir, budget=GATE_BUDGET_SECONDS)
|
|
257
265
|
except (OSError, ValueError) as exc:
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
266
|
+
if enforcement == "required":
|
|
267
|
+
payload = {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"}
|
|
268
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
269
|
+
return
|
|
270
|
+
cf_log.append_event(project_root, "stop_check", {"gate": "invalid_active_nonfatal", "error": str(exc)}, sid)
|
|
271
|
+
files = []
|
|
272
|
+
else:
|
|
273
|
+
if done.decision != "pass":
|
|
274
|
+
reason = done.message or "当前 TASK required Spec verifier/Evidence 未通过;修复或重新对齐后再 Done。"
|
|
275
|
+
if any(item.get("error_code") == "verifier_budget_exhausted" for item in done.evidence):
|
|
276
|
+
reason += "(验证预算不足,部分 verifier 未运行,请拆分 TASK 或减少验证命令)"
|
|
277
|
+
if enforcement == "required":
|
|
278
|
+
payload = {"decision": "block", "reason": reason}
|
|
279
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
280
|
+
return
|
|
281
|
+
cf_log.append_event(project_root, "stop_check", {"gate": "blocked_nonfatal", "reason": reason}, sid)
|
|
282
|
+
files = list(done.files)
|
|
269
283
|
if not resolve_quality_loop(config)["stop_check"]:
|
|
270
284
|
return
|
|
271
285
|
if not has_active:
|
|
@@ -280,6 +294,13 @@ def main() -> None:
|
|
|
280
294
|
failures = acceptance_failures + failures
|
|
281
295
|
if not failures:
|
|
282
296
|
return # 全过或无 validation.yml 且无验收缺口时静默
|
|
297
|
+
if enforcement == "warn":
|
|
298
|
+
cf_log.append_event(
|
|
299
|
+
project_root, "stop_check",
|
|
300
|
+
{"failures": [item["name"] for item in failures], "nonfatal": True},
|
|
301
|
+
sid,
|
|
302
|
+
)
|
|
303
|
+
return
|
|
283
304
|
payload = {"decision": "block", "reason": _reason_text(failures, truncated)}
|
|
284
305
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
285
306
|
except Exception as exc:
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cf-sync: one-command canonical → deploy sync for dual-copy artifacts.
|
|
3
|
+
|
|
4
|
+
Pairs (canonical source → deployed live copy), each with an explicit include
|
|
5
|
+
prefix list — project-owned trees (specs/, tasks/) and deploy-only files
|
|
6
|
+
(config.toml, CLAUDE.md, opencode.json) are never touched:
|
|
7
|
+
|
|
8
|
+
src/core/code-flow → .code-flow (scripts/, config.yml, .version, validation.yml, .gitignore)
|
|
9
|
+
src/adapters/claude → .claude (commands/, settings.local.json)
|
|
10
|
+
src/adapters/codex → .codex (hooks.json)
|
|
11
|
+
src/adapters/codex/skills → .agents/skills (everything)
|
|
12
|
+
src/adapters/costrict → .costrict (commands/, settings.local.json)
|
|
13
|
+
src/adapters/opencode → .opencode (commands/, plugins/)
|
|
14
|
+
|
|
15
|
+
`check` (default) reports drift; `sync` copies canonical → deploy. Deploy-only
|
|
16
|
+
files are never deleted (use --prune to remove files whose source side no
|
|
17
|
+
longer contains them within the include prefixes).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import filecmp
|
|
22
|
+
import os
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
from typing import IO, Optional, Sequence
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
PAIRS = (
|
|
30
|
+
("src/core/code-flow", ".code-flow", ("scripts/", ".version", ".gitignore")),
|
|
31
|
+
("src/adapters/claude", ".claude", ("commands/",)),
|
|
32
|
+
("src/adapters/codex", ".codex", ("hooks.json",)),
|
|
33
|
+
("src/adapters/codex/skills", ".agents/skills", ("*",)),
|
|
34
|
+
("src/adapters/costrict", ".costrict", ("commands/",)),
|
|
35
|
+
("src/adapters/opencode", ".opencode", ("commands/", "plugins/")),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_IGNORED_DIRS = {"__pycache__", ".git", "node_modules", "dist", "build"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _included(relative: str, prefixes: Sequence[str]) -> bool:
|
|
42
|
+
return any(prefix == "*" or relative.startswith(prefix) for prefix in prefixes)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _walk(source: Path) -> list[str]:
|
|
46
|
+
files = []
|
|
47
|
+
for current, dirs, names in os.walk(source):
|
|
48
|
+
dirs[:] = sorted(item for item in dirs if item not in _IGNORED_DIRS)
|
|
49
|
+
base = Path(current)
|
|
50
|
+
for name in names:
|
|
51
|
+
files.append(str((base / name).relative_to(source)))
|
|
52
|
+
return sorted(files)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _relative(relative: str) -> str:
|
|
56
|
+
return relative.replace("\\", "/")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pair_diffs(root: Path, source_rel: str, deploy_rel: str, prefixes: Sequence[str]) -> tuple[list[str], list[str], list[str]]:
|
|
60
|
+
"""Return (differ, missing_in_deploy, deploy_only_within_prefixes)."""
|
|
61
|
+
source = root / source_rel
|
|
62
|
+
deploy = root / deploy_rel
|
|
63
|
+
differ: list[str] = []
|
|
64
|
+
missing: list[str] = []
|
|
65
|
+
for relative in _walk(source):
|
|
66
|
+
if not _included(_relative(relative), prefixes):
|
|
67
|
+
continue
|
|
68
|
+
source_file = source / relative
|
|
69
|
+
deploy_file = deploy / relative
|
|
70
|
+
if not deploy_file.exists():
|
|
71
|
+
missing.append(relative)
|
|
72
|
+
elif not filecmp.cmp(source_file, deploy_file, shallow=False):
|
|
73
|
+
differ.append(relative)
|
|
74
|
+
deploy_only: list[str] = []
|
|
75
|
+
if deploy.is_dir():
|
|
76
|
+
deploy_relative = set(_walk(deploy))
|
|
77
|
+
source_relative = {_relative(item) for item in _walk(source) if _included(_relative(item), prefixes)}
|
|
78
|
+
deploy_only = sorted(item for item in deploy_relative if item not in source_relative and _included(item, prefixes))
|
|
79
|
+
return differ, missing, deploy_only
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _report(stdout: IO[str], root: Path, verbose: bool) -> int:
|
|
83
|
+
drift = 0
|
|
84
|
+
for source_rel, deploy_rel, prefixes in PAIRS:
|
|
85
|
+
differ, missing, deploy_only = _pair_diffs(root, source_rel, deploy_rel, prefixes)
|
|
86
|
+
status = "OK " if not (differ or missing) else "DRIFT"
|
|
87
|
+
stdout.write(f"{status} {source_rel} -> {deploy_rel}\n")
|
|
88
|
+
for relative in differ:
|
|
89
|
+
stdout.write(f" diff {deploy_rel}/{relative}\n")
|
|
90
|
+
drift += 1
|
|
91
|
+
for relative in missing:
|
|
92
|
+
stdout.write(f" miss {deploy_rel}/{relative}\n")
|
|
93
|
+
drift += 1
|
|
94
|
+
if verbose and deploy_only:
|
|
95
|
+
stdout.write(f" only-in-deploy ({len(deploy_only)}):\n")
|
|
96
|
+
for relative in deploy_only[:10]:
|
|
97
|
+
stdout.write(f" {deploy_rel}/{relative}\n")
|
|
98
|
+
return drift
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _sync(root: Path, stdout: IO[str]) -> int:
|
|
102
|
+
copied = 0
|
|
103
|
+
for source_rel, deploy_rel, prefixes in PAIRS:
|
|
104
|
+
source = root / source_rel
|
|
105
|
+
deploy = root / deploy_rel
|
|
106
|
+
for relative in _walk(source):
|
|
107
|
+
if not _included(_relative(relative), prefixes):
|
|
108
|
+
continue
|
|
109
|
+
source_file = source / relative
|
|
110
|
+
deploy_file = deploy / relative
|
|
111
|
+
if deploy_file.exists() and filecmp.cmp(source_file, deploy_file, shallow=False):
|
|
112
|
+
continue
|
|
113
|
+
deploy_file.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
shutil.copy2(source_file, deploy_file)
|
|
115
|
+
stdout.write(f"sync {deploy_rel}/{relative}\n")
|
|
116
|
+
copied += 1
|
|
117
|
+
return copied
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
121
|
+
parser = argparse.ArgumentParser(prog="cf_sync.py")
|
|
122
|
+
parser.add_argument("action", choices=("check", "sync"), nargs="?", default="check")
|
|
123
|
+
parser.add_argument("--root", default=".")
|
|
124
|
+
parser.add_argument("--verbose", action="store_true")
|
|
125
|
+
args = parser.parse_args(argv)
|
|
126
|
+
root = Path(args.root).resolve()
|
|
127
|
+
if args.action == "check":
|
|
128
|
+
drift = _report(stdout, root, args.verbose)
|
|
129
|
+
if drift:
|
|
130
|
+
stdout.write(f"{drift} file(s) drifted; run `cf_sync.py sync` to deploy.\n")
|
|
131
|
+
return 1
|
|
132
|
+
stdout.write("all pairs in sync.\n")
|
|
133
|
+
return 0
|
|
134
|
+
copied = _sync(root, stdout)
|
|
135
|
+
stdout.write(f"{copied} file(s) deployed.\n")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
raise SystemExit(main())
|
|
@@ -6,7 +6,8 @@ from __future__ import annotations
|
|
|
6
6
|
from dataclasses import dataclass, replace
|
|
7
7
|
import hashlib
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
|
|
9
|
+
import time
|
|
10
|
+
from typing import Mapping, Optional
|
|
10
11
|
|
|
11
12
|
from cf_spec_context import (
|
|
12
13
|
BindingInput,
|
|
@@ -19,11 +20,13 @@ from cf_spec_context import (
|
|
|
19
20
|
load_active_task,
|
|
20
21
|
load_context,
|
|
21
22
|
pause_active_task,
|
|
23
|
+
resync_active_hash,
|
|
22
24
|
save_context,
|
|
23
25
|
)
|
|
24
26
|
from cf_spec_gate import validate_stage
|
|
25
27
|
from cf_spec_metadata import load_spec_metadata
|
|
26
28
|
from cf_spec_resolver import resolve_candidates, SpecCandidate
|
|
29
|
+
from cf_spec_session import context_sha256
|
|
27
30
|
from cf_spec_verify import VerificationEvidence, VerificationScope, run_all_verifiers
|
|
28
31
|
|
|
29
32
|
|
|
@@ -40,6 +43,7 @@ class DoneResult:
|
|
|
40
43
|
decision: str
|
|
41
44
|
files: tuple[str, ...]
|
|
42
45
|
evidence: tuple[Mapping[str, object], ...]
|
|
46
|
+
message: str = ""
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
def _context_path(task_dir: str) -> str:
|
|
@@ -71,6 +75,9 @@ def evaluate_scope(root: str, task_dir: str) -> ScopeResult:
|
|
|
71
75
|
for candidate in new
|
|
72
76
|
)
|
|
73
77
|
save_context(_context_path(task_dir), bind_specs(context, selections))
|
|
78
|
+
# The toolchain expanded the context itself; re-sync the marker so this
|
|
79
|
+
# automatic change does not surface as active_context_drift with no recovery.
|
|
80
|
+
resync_active_hash(root, task_dir, context_sha256(load_context(_context_path(task_dir))))
|
|
74
81
|
required = tuple(candidate.spec_id for candidate in new if _required_candidate(candidate))
|
|
75
82
|
if required:
|
|
76
83
|
pause_active_task(root)
|
|
@@ -107,8 +114,20 @@ def _update_rule(rule: RuleBinding, evidence: Mapping[str, object]) -> RuleBindi
|
|
|
107
114
|
return rule
|
|
108
115
|
statuses = dict(rule.stage_status)
|
|
109
116
|
current = statuses["code"]
|
|
117
|
+
if current.status in ("not_applicable", "waived"):
|
|
118
|
+
return rule
|
|
110
119
|
status = "verified" if evidence.get("status") == "verified" else "unverified"
|
|
111
|
-
|
|
120
|
+
if evidence.get("error_code") == "skipped_in_cheap_gate":
|
|
121
|
+
signature = (evidence.get("verifier_ref"), evidence.get("result_sha256"), None)
|
|
122
|
+
else:
|
|
123
|
+
signature = (evidence.get("verifier_ref"), evidence.get("result_sha256"), evidence.get("diff_sha256"))
|
|
124
|
+
if any(
|
|
125
|
+
(item.get("verifier_ref"), item.get("result_sha256"), item.get("diff_sha256")) == signature
|
|
126
|
+
for item in current.evidence
|
|
127
|
+
):
|
|
128
|
+
statuses["code"] = replace(current, status=status)
|
|
129
|
+
else:
|
|
130
|
+
statuses["code"] = replace(current, status=status, evidence=(*current.evidence, evidence))
|
|
112
131
|
return replace(rule, stage_status=statuses)
|
|
113
132
|
|
|
114
133
|
|
|
@@ -125,18 +144,41 @@ def _apply_evidence(context: SpecContext, evidence: tuple[Mapping[str, object],
|
|
|
125
144
|
return replace(context, bindings=tuple(bindings))
|
|
126
145
|
|
|
127
146
|
|
|
128
|
-
def
|
|
147
|
+
def _rule_manual_confirmation(rule: RuleBinding) -> Optional[Mapping[str, object]]:
|
|
148
|
+
status = rule.stage_status.get("code")
|
|
149
|
+
if status is None or status.decision is None or status.decision.kind != "manual_verification":
|
|
150
|
+
return None
|
|
151
|
+
decision = status.decision
|
|
152
|
+
return {
|
|
153
|
+
"reason": decision.reason,
|
|
154
|
+
"confirmed_by": decision.confirmed_by,
|
|
155
|
+
"confirmed_at": decision.confirmed_at,
|
|
156
|
+
"source": decision.source,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None) -> DoneResult:
|
|
129
161
|
scope_result = evaluate_scope(root, task_dir)
|
|
130
162
|
if scope_result.decision == "pause":
|
|
131
|
-
return DoneResult("block", scope_result.files, ())
|
|
163
|
+
return DoneResult("block", scope_result.files, (), scope_result.message)
|
|
132
164
|
context = load_context(_context_path(task_dir))
|
|
133
165
|
diff_hash = _diff_hash(root, scope_result.files)
|
|
134
166
|
all_evidence: list[Mapping[str, object]] = []
|
|
167
|
+
started = time.monotonic()
|
|
135
168
|
for binding in context.bindings:
|
|
136
169
|
metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / binding.path))
|
|
137
|
-
|
|
170
|
+
confirmations: dict[str, Mapping[str, object]] = {}
|
|
171
|
+
for rule in binding.rules:
|
|
172
|
+
confirmation = _rule_manual_confirmation(rule)
|
|
173
|
+
if confirmation is not None:
|
|
174
|
+
confirmations[rule.ref] = confirmation
|
|
175
|
+
remaining = None if budget is None else budget - (time.monotonic() - started)
|
|
176
|
+
result = run_all_verifiers(
|
|
177
|
+
metadata, VerificationScope(root, scope_result.files, diff_hash), confirmations, cheap, remaining
|
|
178
|
+
)
|
|
138
179
|
all_evidence.extend(_evidence_data(item) for item in result.evidence)
|
|
139
180
|
updated = _apply_evidence(context, tuple(all_evidence))
|
|
140
|
-
|
|
141
|
-
|
|
181
|
+
if updated != context:
|
|
182
|
+
save_context(_context_path(task_dir), updated)
|
|
183
|
+
gate = validate_stage(updated, "code", diff_sha256=diff_hash)
|
|
142
184
|
return DoneResult(gate.decision, scope_result.files, tuple(all_evidence))
|
|
@@ -8,13 +8,29 @@ import sys
|
|
|
8
8
|
|
|
9
9
|
import cf_log
|
|
10
10
|
from cf_checks import detect_correction
|
|
11
|
-
from cf_core import
|
|
11
|
+
from cf_core import (
|
|
12
|
+
_log,
|
|
13
|
+
ensure_utf8_io,
|
|
14
|
+
load_config,
|
|
15
|
+
normalize_path,
|
|
16
|
+
resolve_enforcement,
|
|
17
|
+
resolve_quality_loop,
|
|
18
|
+
resolve_session_id,
|
|
19
|
+
)
|
|
20
|
+
from cf_session_state import load_session_state, save_session_state
|
|
12
21
|
from cf_spec_router import RouterError, route_prompt
|
|
13
22
|
|
|
14
23
|
|
|
15
24
|
_PATH_RE = re.compile(r'[@`]?([a-zA-Z0-9_.][a-zA-Z0-9_./\\\-]*\.[a-zA-Z]{1,6})(?![a-zA-Z0-9_])')
|
|
16
25
|
_EXT_RE = re.compile(r'\.(py|js|ts|go|rs|java|rb|cs|cpp|c|h)$')
|
|
17
26
|
|
|
27
|
+
_COMPRESS_INTERVAL = 25
|
|
28
|
+
_REINJECT_INTERVAL = 25
|
|
29
|
+
_REMINDER_TEXT = (
|
|
30
|
+
"⚠ 本会话已处理较多轮次,上下文较长;批量任务建议在任务间压缩上下文,或拆到新会话继续。"
|
|
31
|
+
)
|
|
32
|
+
_WARN_TEXT = "⚠ Spec Workflow 校验未通过(warn 模式):{message} — 建议运行 cf-spec doctor 检查。"
|
|
33
|
+
|
|
18
34
|
|
|
19
35
|
def extract_paths_from_prompt(prompt: str) -> list[str]:
|
|
20
36
|
"""Extract explicit file paths without semantic keyword inference."""
|
|
@@ -46,6 +62,49 @@ def _record_correction(root: str, prompt: str, paths: list[str], sid: str) -> No
|
|
|
46
62
|
cf_log.append_event(root, "correction", {"phrase": correction["phrase"], "prompt_head": prompt[:200], "files": paths}, sid)
|
|
47
63
|
|
|
48
64
|
|
|
65
|
+
def _session_reminder(root: str, sid: str) -> str:
|
|
66
|
+
"""One-line context-compress nudge once per COMPRESS_INTERVAL prompts per session.
|
|
67
|
+
|
|
68
|
+
Gated by quality_loop.compress_reminder; disabled → zero IO. Never raises: a
|
|
69
|
+
corrupt state file degrades to no reminder rather than breaking the hook.
|
|
70
|
+
"""
|
|
71
|
+
config = load_config(root)
|
|
72
|
+
if not config or not resolve_quality_loop(config)["compress_reminder"]:
|
|
73
|
+
return ""
|
|
74
|
+
state = load_session_state(root)
|
|
75
|
+
try:
|
|
76
|
+
if state.get("session_id") != sid:
|
|
77
|
+
state = {"session_id": sid, "prompt_count": 0, "next_remind_at": _COMPRESS_INTERVAL}
|
|
78
|
+
state["prompt_count"] = int(state.get("prompt_count", 0)) + 1
|
|
79
|
+
reminder = ""
|
|
80
|
+
if state["prompt_count"] >= int(state.get("next_remind_at", _COMPRESS_INTERVAL)):
|
|
81
|
+
reminder = _REMINDER_TEXT
|
|
82
|
+
state["next_remind_at"] = int(state["next_remind_at"]) + _COMPRESS_INTERVAL
|
|
83
|
+
except (ValueError, TypeError):
|
|
84
|
+
return ""
|
|
85
|
+
save_session_state(root, state)
|
|
86
|
+
return reminder
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _route_with_enforcement(root: str, paths: list[str], sid: str, enforcement: str) -> tuple[object, object]:
|
|
90
|
+
"""Route with enforcement-aware failure handling.
|
|
91
|
+
|
|
92
|
+
required — fail closed with the SPEC_WORKFLOW_BLOCKED message;
|
|
93
|
+
warn — degrade to a short advisory message;
|
|
94
|
+
inject — degrade silently.
|
|
95
|
+
Returns (result, error_payload); exactly one is not None.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
return route_prompt(root, paths, sid), None
|
|
99
|
+
except RouterError as exc:
|
|
100
|
+
if enforcement == "required":
|
|
101
|
+
message = f"SPEC_WORKFLOW_BLOCKED [{exc.code}]: {exc}. Run cf-spec doctor before continuing."
|
|
102
|
+
return None, _payload(message, "blocked")
|
|
103
|
+
if enforcement == "warn":
|
|
104
|
+
return None, _payload(_WARN_TEXT.format(message=str(exc)), "warn")
|
|
105
|
+
return None, None
|
|
106
|
+
|
|
107
|
+
|
|
49
108
|
def main() -> None:
|
|
50
109
|
try:
|
|
51
110
|
ensure_utf8_io()
|
|
@@ -58,16 +117,53 @@ def main() -> None:
|
|
|
58
117
|
return
|
|
59
118
|
root = os.getcwd()
|
|
60
119
|
sid = resolve_session_id(data)
|
|
120
|
+
config = load_config(root)
|
|
121
|
+
enforcement = resolve_enforcement(config) if config else "required"
|
|
61
122
|
paths = extract_paths_from_prompt(prompt)
|
|
62
123
|
_record_correction(root, prompt, paths, sid)
|
|
63
|
-
result =
|
|
64
|
-
if not
|
|
124
|
+
result, error_payload = _route_with_enforcement(root, paths, sid, enforcement)
|
|
125
|
+
if error_payload is not None:
|
|
126
|
+
sys.stdout.write(json.dumps(error_payload, ensure_ascii=False))
|
|
65
127
|
return
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
128
|
+
if result is None:
|
|
129
|
+
return
|
|
130
|
+
reminder = ""
|
|
131
|
+
if config and resolve_quality_loop(config)["compress_reminder"]:
|
|
132
|
+
reminder = _session_reminder(root, sid)
|
|
133
|
+
text = result.text
|
|
134
|
+
if result.mode == "task" and result.context_sha256:
|
|
135
|
+
# Dedup: inject the projection once per session/context, refreshing
|
|
136
|
+
# at the compress cadence so long sessions do not re-pay 12k chars
|
|
137
|
+
# on every prompt.
|
|
138
|
+
state = load_session_state(root)
|
|
139
|
+
count = int(state.get("prompt_count", 0))
|
|
140
|
+
dirty = False
|
|
141
|
+
if not (config and resolve_quality_loop(config)["compress_reminder"]):
|
|
142
|
+
# compress_reminder off → _session_reminder never bumps the
|
|
143
|
+
# counter; maintain the cadence here so the periodic refresh
|
|
144
|
+
# still fires after compaction.
|
|
145
|
+
count += 1
|
|
146
|
+
state["prompt_count"] = count
|
|
147
|
+
dirty = True
|
|
148
|
+
injected = state.get("injected_sha256")
|
|
149
|
+
last_inject = int(state.get("last_inject_prompt", 0))
|
|
150
|
+
if injected == result.context_sha256 and count - last_inject < _REINJECT_INTERVAL:
|
|
151
|
+
text = ""
|
|
152
|
+
else:
|
|
153
|
+
state["injected_sha256"] = result.context_sha256
|
|
154
|
+
state["last_inject_prompt"] = count
|
|
155
|
+
dirty = True
|
|
156
|
+
if dirty:
|
|
157
|
+
save_session_state(root, state)
|
|
158
|
+
if not text and not reminder:
|
|
159
|
+
return
|
|
160
|
+
if text:
|
|
161
|
+
cf_log.append_event(root, "inject", {"specs": list(result.specs), "mode": result.mode, "source": result.mode}, sid)
|
|
162
|
+
if text and reminder:
|
|
163
|
+
text = text.rstrip() + "\n\n" + reminder
|
|
164
|
+
elif reminder:
|
|
165
|
+
text = reminder
|
|
166
|
+
sys.stdout.write(json.dumps(_payload(text, result.mode if text else "reminder"), ensure_ascii=False))
|
|
71
167
|
except (json.JSONDecodeError, UnicodeError, OSError, ValueError) as exc:
|
|
72
168
|
_log(f"cf_user_prompt_hook error: {exc}")
|
|
73
169
|
|