@michaelxwb/code-flow 0.6.5 → 0.8.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/README.md +47 -3
- package/docs/USAGE.md +1529 -0
- package/docs/acceptance-contract.md +217 -0
- package/docs/ci/acceptance-evidence.md +77 -0
- package/docs/migrations/runtime-0.7.0.md +41 -0
- package/docs/migrations/runtime-0.8.0.md +154 -0
- package/docs/migrations/runtime-0.9.0.md +40 -0
- package/docs/spec-injection-hardening.md +54 -0
- package/docs/spec-injection-performance.json +73 -0
- package/docs/spec-optimization.md +12 -0
- package/package.json +18 -3
- package/src/adapters/claude/CLAUDE.md +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -3
- package/src/adapters/claude/commands/cf-spec.md +23 -3
- package/src/adapters/claude/commands/cf-stats.md +11 -1
- package/src/adapters/claude/commands/cf-sync.md +20 -6
- package/src/adapters/claude/commands/cf-task/align.md +25 -3
- package/src/adapters/claude/commands/cf-task/archive.md +36 -42
- package/src/adapters/claude/commands/cf-task/block.md +15 -2
- package/src/adapters/claude/commands/cf-task/graph.md +13 -1
- package/src/adapters/claude/commands/cf-task/note.md +13 -1
- package/src/adapters/claude/commands/cf-task/plan.md +137 -23
- package/src/adapters/claude/commands/cf-task/prd.md +13 -2
- package/src/adapters/claude/commands/cf-task/start.md +52 -39
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
- package/src/adapters/claude/commands/cf-validate.md +30 -5
- package/src/adapters/codex/AGENTS.md +1 -1
- package/src/adapters/codex/hooks.json +14 -10
- package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
- package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
- package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -42
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +52 -39
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +69 -35
- package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
- package/src/adapters/commands.json +115 -0
- package/src/adapters/costrict/CLAUDE.md +1 -1
- package/src/adapters/costrict/commands/cf-init.md +1 -3
- package/src/adapters/costrict/commands/cf-spec.md +23 -3
- package/src/adapters/costrict/commands/cf-stats.md +11 -1
- package/src/adapters/costrict/commands/cf-sync.md +20 -6
- package/src/adapters/costrict/commands/cf-task/align.md +25 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +36 -42
- package/src/adapters/costrict/commands/cf-task/block.md +15 -2
- package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
- package/src/adapters/costrict/commands/cf-task/note.md +13 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
- package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
- package/src/adapters/costrict/commands/cf-task/start.md +52 -39
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
- package/src/adapters/costrict/commands/cf-validate.md +30 -5
- package/src/adapters/install-plan.json +100 -0
- package/src/adapters/opencode/AGENTS.md +1 -1
- package/src/adapters/opencode/commands/cf-init.md +7 -7
- package/src/adapters/opencode/commands/cf-learn.md +6 -6
- package/src/adapters/opencode/commands/cf-spec.md +34 -2
- package/src/adapters/opencode/commands/cf-stats.md +17 -7
- package/src/adapters/opencode/commands/cf-sync.md +21 -6
- package/src/adapters/opencode/commands/cf-task/align.md +25 -8
- package/src/adapters/opencode/commands/cf-task/archive.md +36 -42
- package/src/adapters/opencode/commands/cf-task/block.md +16 -2
- package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
- package/src/adapters/opencode/commands/cf-task/note.md +14 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
- package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
- package/src/adapters/opencode/commands/cf-task/start.md +53 -45
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
- package/src/adapters/opencode/commands/cf-validate.md +35 -9
- package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
- package/src/adapters/platform-bindings.json +370 -0
- package/src/cli.js +369 -316
- package/src/core/code-flow/config.yml +4 -1
- package/src/core/code-flow/runtime-commands.json +259 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
- package/src/core/code-flow/scripts/cf_audit.py +10 -0
- package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
- package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
- package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
- package/src/core/code-flow/scripts/cf_feedback.py +5 -51
- package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
- package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +5 -1741
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
- package/src/core/code-flow/scripts/cf_stats.py +5 -501
- package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
- package/src/core/code-flow/scripts/cf_sync.py +5 -135
- package/src/core/code-flow/scripts/cf_task_index.py +5 -143
- package/src/core/code-flow/scripts/cf_task_parallel.py +5 -389
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
- package/src/core/code-flow/scripts/cf_validation.py +6 -77
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
- package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
- package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
- package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
- package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
- package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +5 -2
- package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
- package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
- package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
- package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +163 -35
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
- package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
- package/src/core/code-flow/scripts/runtime-resources.json +29 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
- package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
- package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
- package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
- package/src/core/code-flow/validation.yml +9 -3
- package/src/migrate/runtime.js +213 -0
- package/src/migrate/spec-workflow.js +16 -1
- package/src/runtime.js +219 -0
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
|
@@ -1,178 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import re
|
|
2
|
+
"""Path-frozen entry shim: implementation lives in codeflow.hooks.cf_user_prompt_hook."""
|
|
7
3
|
import sys
|
|
8
4
|
|
|
9
|
-
import
|
|
10
|
-
from cf_checks import detect_correction
|
|
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
|
-
timing_log,
|
|
20
|
-
)
|
|
21
|
-
from cf_session_state import load_session_state, save_session_state
|
|
22
|
-
from cf_spec_context import injection_version
|
|
23
|
-
from cf_spec_router import RouterError, route_prompt
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
_PATH_RE = re.compile(r'[@`]?([a-zA-Z0-9_.][a-zA-Z0-9_./\\\-]*\.[a-zA-Z]{1,6})(?![a-zA-Z0-9_])')
|
|
27
|
-
_EXT_RE = re.compile(r'\.(py|js|ts|go|rs|java|rb|cs|cpp|c|h)$')
|
|
28
|
-
|
|
29
|
-
_COMPRESS_INTERVAL = 25
|
|
30
|
-
_REINJECT_INTERVAL = 25
|
|
31
|
-
_REMINDER_TEXT = (
|
|
32
|
-
"⚠ 本会话已处理较多轮次,上下文较长;批量任务建议在任务间压缩上下文,或拆到新会话继续。"
|
|
33
|
-
)
|
|
34
|
-
_WARN_TEXT = "⚠ Spec Workflow 校验未通过(warn 模式):{message} — 建议运行 cf-spec doctor 检查。"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def extract_paths_from_prompt(prompt: str) -> list[str]:
|
|
38
|
-
"""Extract explicit file paths without semantic keyword inference."""
|
|
39
|
-
paths = []
|
|
40
|
-
seen = set()
|
|
41
|
-
for match in _PATH_RE.finditer(prompt):
|
|
42
|
-
candidate = normalize_path(match.group(1).lstrip('@`'))
|
|
43
|
-
if candidate not in seen and ('/' in candidate or _EXT_RE.search(candidate)):
|
|
44
|
-
paths.append(candidate)
|
|
45
|
-
seen.add(candidate)
|
|
46
|
-
return paths
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _payload(text: str, mode: str) -> dict[str, object]:
|
|
50
|
-
payload: dict[str, object] = {
|
|
51
|
-
"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": text}
|
|
52
|
-
}
|
|
53
|
-
if os.environ.get("CF_DEBUG") == "1":
|
|
54
|
-
payload["debug"] = {"mode": mode}
|
|
55
|
-
return payload
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _record_correction(root: str, prompt: str, paths: list[str], sid: str) -> None:
|
|
59
|
-
config = load_config(root)
|
|
60
|
-
if not config or not resolve_quality_loop(config)["correction_capture"]:
|
|
61
|
-
return
|
|
62
|
-
correction = detect_correction(prompt)
|
|
63
|
-
if correction:
|
|
64
|
-
cf_log.append_event(root, "correction", {"phrase": correction["phrase"], "prompt_head": prompt[:200], "files": paths}, sid)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _session_reminder(root: str, sid: str) -> str:
|
|
68
|
-
"""One-line context-compress nudge once per COMPRESS_INTERVAL prompts per session.
|
|
69
|
-
|
|
70
|
-
Gated by quality_loop.compress_reminder; disabled → zero IO. Never raises: a
|
|
71
|
-
corrupt state file degrades to no reminder rather than breaking the hook.
|
|
72
|
-
"""
|
|
73
|
-
config = load_config(root)
|
|
74
|
-
if not config or not resolve_quality_loop(config)["compress_reminder"]:
|
|
75
|
-
return ""
|
|
76
|
-
state = load_session_state(root)
|
|
77
|
-
try:
|
|
78
|
-
if state.get("session_id") != sid:
|
|
79
|
-
state = {"session_id": sid, "prompt_count": 0, "next_remind_at": _COMPRESS_INTERVAL}
|
|
80
|
-
state["prompt_count"] = int(state.get("prompt_count", 0)) + 1
|
|
81
|
-
reminder = ""
|
|
82
|
-
if state["prompt_count"] >= int(state.get("next_remind_at", _COMPRESS_INTERVAL)):
|
|
83
|
-
reminder = _REMINDER_TEXT
|
|
84
|
-
state["next_remind_at"] = int(state["next_remind_at"]) + _COMPRESS_INTERVAL
|
|
85
|
-
except (ValueError, TypeError):
|
|
86
|
-
return ""
|
|
87
|
-
save_session_state(root, state)
|
|
88
|
-
return reminder
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _route_with_enforcement(root: str, paths: list[str], sid: str, enforcement: str) -> tuple[object, object]:
|
|
92
|
-
"""Route with enforcement-aware failure handling.
|
|
93
|
-
|
|
94
|
-
required — fail closed with the SPEC_WORKFLOW_BLOCKED message;
|
|
95
|
-
warn — degrade to a short advisory message;
|
|
96
|
-
inject — degrade silently.
|
|
97
|
-
Returns (result, error_payload); exactly one is not None.
|
|
98
|
-
"""
|
|
99
|
-
try:
|
|
100
|
-
return route_prompt(root, paths, sid), None
|
|
101
|
-
except RouterError as exc:
|
|
102
|
-
if enforcement == "required":
|
|
103
|
-
message = f"SPEC_WORKFLOW_BLOCKED [{exc.code}]: {exc}. Run cf-spec doctor before continuing."
|
|
104
|
-
return None, _payload(message, "blocked")
|
|
105
|
-
if enforcement == "warn":
|
|
106
|
-
return None, _payload(_WARN_TEXT.format(message=str(exc)), "warn")
|
|
107
|
-
return None, None
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def main() -> None:
|
|
111
|
-
try:
|
|
112
|
-
ensure_utf8_io()
|
|
113
|
-
raw = sys.stdin.read()
|
|
114
|
-
if not raw.strip():
|
|
115
|
-
return
|
|
116
|
-
data = json.loads(raw)
|
|
117
|
-
prompt = data.get("prompt", "")
|
|
118
|
-
if not isinstance(prompt, str) or not prompt.strip():
|
|
119
|
-
return
|
|
120
|
-
root = os.getcwd()
|
|
121
|
-
sid = resolve_session_id(data)
|
|
122
|
-
config = load_config(root)
|
|
123
|
-
enforcement = resolve_enforcement(config) if config else "required"
|
|
124
|
-
paths = extract_paths_from_prompt(prompt)
|
|
125
|
-
_record_correction(root, prompt, paths, sid)
|
|
126
|
-
result, error_payload = _route_with_enforcement(root, paths, sid, enforcement)
|
|
127
|
-
if error_payload is not None:
|
|
128
|
-
sys.stdout.write(json.dumps(error_payload, ensure_ascii=False))
|
|
129
|
-
return
|
|
130
|
-
if result is None:
|
|
131
|
-
return
|
|
132
|
-
reminder = ""
|
|
133
|
-
if config and resolve_quality_loop(config)["compress_reminder"]:
|
|
134
|
-
reminder = _session_reminder(root, sid)
|
|
135
|
-
text = result.text
|
|
136
|
-
if result.mode == "task" and result.context_sha256:
|
|
137
|
-
# Dedup: inject once per session/task/contract version, refreshing
|
|
138
|
-
# at the compress cadence so long sessions do not re-pay 12k chars
|
|
139
|
-
# on every prompt. The key covers task_id and the TASK contract
|
|
140
|
-
# digest (not just the Context hash) so switching TASKs on a
|
|
141
|
-
# shared Context always re-injects the new contract.
|
|
142
|
-
state = load_session_state(root)
|
|
143
|
-
count = int(state.get("prompt_count", 0))
|
|
144
|
-
dirty = False
|
|
145
|
-
if not (config and resolve_quality_loop(config)["compress_reminder"]):
|
|
146
|
-
# compress_reminder off → _session_reminder never bumps the
|
|
147
|
-
# counter; maintain the cadence here so the periodic refresh
|
|
148
|
-
# still fires after compaction.
|
|
149
|
-
count += 1
|
|
150
|
-
state["prompt_count"] = count
|
|
151
|
-
dirty = True
|
|
152
|
-
version = injection_version(root, sid, result.context_sha256)
|
|
153
|
-
injected = state.get("injected_version")
|
|
154
|
-
last_inject = int(state.get("last_inject_prompt", 0))
|
|
155
|
-
if injected == version and count - last_inject < _REINJECT_INTERVAL:
|
|
156
|
-
text = ""
|
|
157
|
-
else:
|
|
158
|
-
state["injected_version"] = version
|
|
159
|
-
state["last_inject_prompt"] = count
|
|
160
|
-
dirty = True
|
|
161
|
-
if dirty:
|
|
162
|
-
save_session_state(root, state)
|
|
163
|
-
if not text and not reminder:
|
|
164
|
-
return
|
|
165
|
-
if text:
|
|
166
|
-
cf_log.append_event(root, "inject", {"specs": list(result.specs), "mode": result.mode, "source": result.mode}, sid)
|
|
167
|
-
if text and reminder:
|
|
168
|
-
text = text.rstrip() + "\n\n" + reminder
|
|
169
|
-
elif reminder:
|
|
170
|
-
text = reminder
|
|
171
|
-
sys.stdout.write(json.dumps(_payload(text, result.mode if text else "reminder"), ensure_ascii=False))
|
|
172
|
-
except (json.JSONDecodeError, UnicodeError, OSError, ValueError) as exc:
|
|
173
|
-
_log(f"cf_user_prompt_hook error: {exc}")
|
|
174
|
-
|
|
5
|
+
from codeflow.hooks import cf_user_prompt_hook as _impl
|
|
175
6
|
|
|
176
7
|
if __name__ == "__main__":
|
|
177
|
-
main()
|
|
178
|
-
timing_log("cf_user_prompt_hook")
|
|
8
|
+
_impl.main()
|
|
9
|
+
_impl.timing_log("cf_user_prompt_hook")
|
|
10
|
+
else:
|
|
11
|
+
sys.modules[__name__] = _impl
|
|
@@ -1,81 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import argparse
|
|
5
|
-
import json
|
|
6
|
-
import math
|
|
7
|
-
import os
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import IO, Optional, Sequence
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Path-frozen entry shim: implementation lives in codeflow.cli.cf_validation."""
|
|
10
3
|
import sys
|
|
11
4
|
|
|
12
|
-
from
|
|
13
|
-
from cf_spec_context import _git_changes, current_owned_paths, load_active_task
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def validation_scope(root: str, files: Sequence[str] = ()) -> tuple[str, ...]:
|
|
17
|
-
base = Path(root).resolve()
|
|
18
|
-
if files:
|
|
19
|
-
selected = []
|
|
20
|
-
for name in files:
|
|
21
|
-
path = (base / name).resolve()
|
|
22
|
-
if base not in path.parents:
|
|
23
|
-
raise ValueError(f"validation path outside project: {name}")
|
|
24
|
-
selected.append(path.relative_to(base).as_posix())
|
|
25
|
-
return tuple(sorted(set(selected)))
|
|
26
|
-
if (base / ".code-flow/.active-task.json").exists():
|
|
27
|
-
return current_owned_paths(str(base), load_active_task(str(base)))
|
|
28
|
-
return tuple(sorted(_git_changes(str(base))))
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _validators(root: str) -> list[dict[str, object]]:
|
|
32
|
-
from cf_stop_hook import load_validators
|
|
33
|
-
if (Path(root) / ".code-flow/validation.yml").is_file():
|
|
34
|
-
return load_validators(root)
|
|
35
|
-
package = Path(root) / "package.json"
|
|
36
|
-
if not package.is_file():
|
|
37
|
-
return []
|
|
38
|
-
data = json.loads(package.read_text(encoding="utf-8"))
|
|
39
|
-
scripts = data.get("scripts", {}) if isinstance(data, dict) else {}
|
|
40
|
-
if not isinstance(scripts, dict):
|
|
41
|
-
raise ValueError("invalid package scripts")
|
|
42
|
-
return [{"name": name, "trigger": "*", "command": f"npm run {name}", "timeout": 60000}
|
|
43
|
-
for name in ("test", "lint") if isinstance(scripts.get(name), str)]
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def validate_files(root: str, files: Sequence[str] = (), budget: Optional[float] = None) -> dict[str, object]:
|
|
47
|
-
from cf_stop_hook import run_validators
|
|
48
|
-
selected = validation_scope(root, files)
|
|
49
|
-
validators = _validators(root)
|
|
50
|
-
if not selected:
|
|
51
|
-
return {"decision": "pass", "files": [], "reason": "no_changes"}
|
|
52
|
-
if not validators:
|
|
53
|
-
return {"decision": "block", "files": selected, "reason": "no_validators_configured"}
|
|
54
|
-
maximum = budget if budget is not None else sum(float(v.get("timeout", 30000)) / 1000 for v in validators)
|
|
55
|
-
if not math.isfinite(maximum) or maximum <= 0:
|
|
56
|
-
raise ValueError("validation budget must be finite and positive")
|
|
57
|
-
with execution_session():
|
|
58
|
-
failures, truncated = run_validators(root, validators, list(selected), "cf-validate", maximum, strict=True)
|
|
59
|
-
return {"decision": "block" if failures or truncated else "pass", "files": selected,
|
|
60
|
-
"failures": failures, "incomplete": truncated}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
64
|
-
parser = argparse.ArgumentParser()
|
|
65
|
-
parser.add_argument("--root", default=os.getcwd())
|
|
66
|
-
parser.add_argument("--files", nargs="*", default=[])
|
|
67
|
-
parser.add_argument("--budget", type=float)
|
|
68
|
-
parser.add_argument("--scope-only", action="store_true")
|
|
69
|
-
parser.add_argument("--json", action="store_true")
|
|
70
|
-
args = parser.parse_args(argv)
|
|
71
|
-
try:
|
|
72
|
-
result = {"files": validation_scope(args.root, args.files)} if args.scope_only else validate_files(args.root, args.files, args.budget)
|
|
73
|
-
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
74
|
-
return 3 if result.get("decision") == "block" else 0
|
|
75
|
-
except (OSError, ValueError) as exc:
|
|
76
|
-
stdout.write(json.dumps({"decision": "block", "error": str(exc)}, ensure_ascii=False))
|
|
77
|
-
return 2
|
|
78
|
-
|
|
5
|
+
from codeflow.cli import cf_validation as _impl
|
|
79
6
|
|
|
80
7
|
if __name__ == "__main__":
|
|
81
|
-
raise SystemExit(main())
|
|
8
|
+
raise SystemExit(_impl.main())
|
|
9
|
+
else:
|
|
10
|
+
sys.modules[__name__] = _impl
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Append execution history without erasing the user's acceptance contract."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Mapping, Optional, Sequence
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from codeflow.evidence.cf_evidence_store import EvidenceStore, discover_project_root, git_commit
|
|
12
|
+
from codeflow.acceptance.cf_acceptance_schema import check_outcome, load_manifest, recorded_check_hash, scenario_check_ids
|
|
13
|
+
|
|
14
|
+
STATES = frozenset(("planned", "pending", "tbd", "verified", "unverified", "stale", "failed", "incomplete", "not_configured", "e2e_deferred", "env_blocked", "manual_pending", "accepted_risk"))
|
|
15
|
+
_COVERAGE_STATES = STATES | {"designed"}
|
|
16
|
+
_EVIDENCE_STATUS = {
|
|
17
|
+
"passed": "passed",
|
|
18
|
+
"failed": "failed",
|
|
19
|
+
"incomplete": "incomplete",
|
|
20
|
+
"not_configured": "not_configured",
|
|
21
|
+
"e2e_deferred": "deferred",
|
|
22
|
+
"env_blocked": "env_blocked",
|
|
23
|
+
"accepted_risk": "accepted_risk",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def scenario_line(line: str, scenario_id: str) -> bool:
|
|
28
|
+
return bool(re.match(rf"^\s*(?:\|\s*|[-*]\s+){re.escape(scenario_id)}(?=\s*[:|])", line))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def line_status(line: str, scenario_id: str) -> str:
|
|
32
|
+
if not scenario_line(line, scenario_id):
|
|
33
|
+
return ""
|
|
34
|
+
if line.lstrip().startswith("|"):
|
|
35
|
+
cells = [cell.strip().lower() for cell in line.strip().strip("|").split("|")]
|
|
36
|
+
return next((cell for cell in reversed(cells) if cell in _COVERAGE_STATES), "")
|
|
37
|
+
match = re.match(r"^\s*[-*]\s+[^:]+:\s*([a-z0-9_]+)\b", line, re.I)
|
|
38
|
+
return match.group(1).lower() if match and match.group(1).lower() in _COVERAGE_STATES else ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _status_index(header: str) -> int:
|
|
42
|
+
cells = [cell.strip().lower() for cell in header.strip().strip("|").split("|")]
|
|
43
|
+
for index, cell in enumerate(cells):
|
|
44
|
+
if cell in ("状态", "status"):
|
|
45
|
+
return index + 1
|
|
46
|
+
return -1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _status_line(line: str, scenario_id: str, status: str, status_index: int = -1) -> str:
|
|
50
|
+
if not scenario_line(line, scenario_id):
|
|
51
|
+
return line
|
|
52
|
+
if line.lstrip().startswith("|"):
|
|
53
|
+
cells = line.split("|")
|
|
54
|
+
if 0 < status_index < len(cells) - 1:
|
|
55
|
+
cells[status_index] = f" {status} "
|
|
56
|
+
return "|".join(cells)
|
|
57
|
+
for index in range(len(cells) - 1, 0, -1):
|
|
58
|
+
if cells[index].strip().lower() in _COVERAGE_STATES:
|
|
59
|
+
cells[index] = f" {status} "
|
|
60
|
+
return "|".join(cells)
|
|
61
|
+
return line.rstrip().rstrip("|") + f"| {status} |"
|
|
62
|
+
prefix = r"(^\s*[-*]\s+[^:]+:\s*)"
|
|
63
|
+
if line_status(line, scenario_id):
|
|
64
|
+
return re.sub(prefix + r"(?:" + "|".join(sorted(_COVERAGE_STATES)) + r")\b", rf"\g<1>{status}", line, count=1, flags=re.I)
|
|
65
|
+
return re.sub(prefix, rf"\g<1>{status} — ", line, count=1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _patch_section(section: str, scenario_id: str, status: str, record: str) -> str:
|
|
69
|
+
contract = re.search(r"(?ms)^### Acceptance Contract[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
|
|
70
|
+
if contract:
|
|
71
|
+
lines = contract.group(1).splitlines()
|
|
72
|
+
header = next((line for line in lines if line.lstrip().startswith("|")
|
|
73
|
+
and ("状态" in line or "status" in line.lower())), "")
|
|
74
|
+
index = _status_index(header)
|
|
75
|
+
body = "\n".join(_status_line(line, scenario_id, status, index) for line in lines) + "\n"
|
|
76
|
+
section = section[:contract.start(1)] + body + section[contract.end(1):]
|
|
77
|
+
evidence = re.search(r"(?ms)^### Acceptance Evidence[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
|
|
78
|
+
if evidence:
|
|
79
|
+
body = evidence.group(1)
|
|
80
|
+
if record not in body.splitlines():
|
|
81
|
+
body = body.rstrip() + "\n" + record + "\n\n"
|
|
82
|
+
section = section[:evidence.start(1)] + body + section[evidence.end(1):]
|
|
83
|
+
else:
|
|
84
|
+
section = section.rstrip() + "\n\n### Acceptance Evidence\n" + record + "\n"
|
|
85
|
+
return section
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def sync_task_evidence(task: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "", status: str = "verified") -> str:
|
|
89
|
+
"""Sync one scenario's status into the task file; returns what was written.
|
|
90
|
+
|
|
91
|
+
- owner + TASK section → contract/evidence + coverage
|
|
92
|
+
- ownerless legacy scenario → coverage only (a scenario with neither owner
|
|
93
|
+
nor coverage has no truthful place to attribute evidence; first-TASK
|
|
94
|
+
attribution corrupted unrelated contracts, so it is skipped)
|
|
95
|
+
"""
|
|
96
|
+
text = task.read_text(encoding="utf-8")
|
|
97
|
+
record = f"- {scenario_id}: {status} — {evidence} (confirmed_by: {confirmed_by})"
|
|
98
|
+
if owner:
|
|
99
|
+
task_pattern = re.escape(owner)
|
|
100
|
+
match = re.search(rf"(?ms)^##\s+{task_pattern}:.*?(?=^## |\Z)", text)
|
|
101
|
+
if match is None:
|
|
102
|
+
raise ValueError(f"acceptance owner missing: {owner}")
|
|
103
|
+
section = _patch_section(match.group(0), scenario_id, status, record)
|
|
104
|
+
text = text[:match.start()] + section + text[match.end():]
|
|
105
|
+
coverage = re.search(r"(?ms)^## Acceptance Coverage[^\n]*\n(.*?)(?=^## |\Z)", text)
|
|
106
|
+
if coverage:
|
|
107
|
+
lines = coverage.group(1).splitlines()
|
|
108
|
+
header = next((line for line in lines if line.lstrip().startswith("|")
|
|
109
|
+
and ("状态" in line or "status" in line.lower())), "")
|
|
110
|
+
index = _status_index(header)
|
|
111
|
+
body = "\n".join(_status_line(line, scenario_id, status, index) for line in lines) + "\n"
|
|
112
|
+
text = text[:coverage.start(1)] + body + text[coverage.end(1):]
|
|
113
|
+
elif not owner:
|
|
114
|
+
return "skipped_no_owner"
|
|
115
|
+
from codeflow.core.cf_fs import atomic_write_text
|
|
116
|
+
atomic_write_text(task, text)
|
|
117
|
+
return "synced"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _run_record(row: Mapping[str, object], result: Mapping[str, object], run_id: str,
|
|
121
|
+
executed_at: str, fingerprint: str, commit: str,
|
|
122
|
+
check_config: Optional[Mapping[str, object]] = None) -> dict[str, object]:
|
|
123
|
+
source = check_config if isinstance(check_config, Mapping) else row
|
|
124
|
+
command = source.get("command")
|
|
125
|
+
if not isinstance(command, list):
|
|
126
|
+
command = []
|
|
127
|
+
timeout = source.get("timeout", 60)
|
|
128
|
+
cwd = source.get("cwd", ".")
|
|
129
|
+
state = str(result.get("status"))
|
|
130
|
+
exit_code = result.get("exit_code")
|
|
131
|
+
return {
|
|
132
|
+
"run_id": run_id,
|
|
133
|
+
"check_id": str(row.get("check") or row.get("id")),
|
|
134
|
+
"scenario_id": str(row.get("id")),
|
|
135
|
+
"command": command,
|
|
136
|
+
"cwd": cwd if isinstance(cwd, str) else ".",
|
|
137
|
+
"timeout": timeout if isinstance(timeout, (int, float)) else 60,
|
|
138
|
+
"input_hash": fingerprint if isinstance(fingerprint, str) else "",
|
|
139
|
+
"exit_code": exit_code if isinstance(exit_code, int) else (0 if state == "passed" else 1),
|
|
140
|
+
"status": _EVIDENCE_STATUS[state],
|
|
141
|
+
"executed_at": executed_at,
|
|
142
|
+
"source": str(result.get("source") or "local"),
|
|
143
|
+
"environment_hash": str(result.get("environment_hash") or ""),
|
|
144
|
+
"commit": commit,
|
|
145
|
+
"run_url": str(result.get("run_url") or ""),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _outcome_mapping(result: Mapping[str, object], field: str) -> Mapping[str, object]:
|
|
150
|
+
value = result.get(field)
|
|
151
|
+
return value if isinstance(value, Mapping) else {}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _store_evidence_runs(path: Path, rows: Mapping[str, Mapping[str, object]],
|
|
155
|
+
results: Sequence[Mapping[str, object]], run_id: str,
|
|
156
|
+
executed_at: str, fingerprint: str) -> dict[str, int]:
|
|
157
|
+
from codeflow.evidence.cf_evidence_store import requirement_key
|
|
158
|
+
project = discover_project_root(path.parent)
|
|
159
|
+
store = EvidenceStore(project, requirement_key(path.parent))
|
|
160
|
+
commit = git_commit(project)
|
|
161
|
+
created = duplicates = 0
|
|
162
|
+
for result in results:
|
|
163
|
+
state = str(result.get("status"))
|
|
164
|
+
if state not in _EVIDENCE_STATUS:
|
|
165
|
+
continue
|
|
166
|
+
if result.get("cache_reused") is True and result.get("checkpoint_reused") is not True:
|
|
167
|
+
continue
|
|
168
|
+
row = rows.get(str(result.get("id")))
|
|
169
|
+
if row is None:
|
|
170
|
+
continue
|
|
171
|
+
result_fingerprint = str(result.get("input_hash") or fingerprint)
|
|
172
|
+
raw_ids = result.get("check_ids")
|
|
173
|
+
check_ids = [str(value) for value in raw_ids] if isinstance(raw_ids, list) else []
|
|
174
|
+
check_id = str(row.get("check") or row.get("id"))
|
|
175
|
+
if not check_ids:
|
|
176
|
+
check_ids = [check_id]
|
|
177
|
+
check_hashes = _outcome_mapping(result, "check_hashes")
|
|
178
|
+
check_reused = _outcome_mapping(result, "check_reused")
|
|
179
|
+
check_checkpoint = _outcome_mapping(result, "checkpoint_reused_checks")
|
|
180
|
+
configs = _outcome_mapping(result, "check_configs")
|
|
181
|
+
for item_id in check_ids:
|
|
182
|
+
checkpointed = check_checkpoint.get(item_id, result.get("checkpoint_reused"))
|
|
183
|
+
item_hash = str(check_hashes.get(item_id) or result_fingerprint)
|
|
184
|
+
if check_reused.get(item_id) is True:
|
|
185
|
+
if checkpointed is not True:
|
|
186
|
+
continue
|
|
187
|
+
if any(
|
|
188
|
+
run.get("input_hash") == item_hash
|
|
189
|
+
and run.get("status") in ("passed", "verified")
|
|
190
|
+
for run in store.list_runs(item_id)
|
|
191
|
+
):
|
|
192
|
+
continue
|
|
193
|
+
config = configs.get(item_id)
|
|
194
|
+
record = _run_record(
|
|
195
|
+
row, check_outcome(result, item_id), run_id, executed_at, item_hash, commit,
|
|
196
|
+
config if isinstance(config, Mapping) else None,
|
|
197
|
+
)
|
|
198
|
+
record["check_id"] = item_id
|
|
199
|
+
outcome = store.write_run(item_id, record)
|
|
200
|
+
created += outcome["status"] == "created"
|
|
201
|
+
duplicates += outcome["status"] == "duplicate"
|
|
202
|
+
return {"created": created, "duplicates": duplicates}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _restore_checkpoint(row: dict[str, object], result: Mapping[str, object], run_key: str,
|
|
206
|
+
executed_at: str, fingerprint: str) -> Optional[tuple[dict[str, object], str]]:
|
|
207
|
+
if result["status"] != "passed" or row.get("status") == "verified":
|
|
208
|
+
return None
|
|
209
|
+
row["revision"] = int(row.get("revision", 0)) + 1
|
|
210
|
+
record: dict[str, object] = {
|
|
211
|
+
"id": result["id"],
|
|
212
|
+
"kind": str(result.get("kind", "functional")),
|
|
213
|
+
"status": "passed",
|
|
214
|
+
"exit_code": 0,
|
|
215
|
+
"cache_reused": True,
|
|
216
|
+
"checkpoint_reused": True,
|
|
217
|
+
"revision": row["revision"],
|
|
218
|
+
"run_id": run_key,
|
|
219
|
+
"executed_at": executed_at,
|
|
220
|
+
"command": row.get("command"),
|
|
221
|
+
"cwd": row.get("cwd", "."),
|
|
222
|
+
"timeout": row.get("timeout", 60),
|
|
223
|
+
}
|
|
224
|
+
if fingerprint:
|
|
225
|
+
record["fingerprint"] = fingerprint
|
|
226
|
+
for key in ("check_ids", "check_hashes", "check_results", "check_reused", "checkpoint_reused_checks", "check_configs", "input_hash", "risk"):
|
|
227
|
+
if key in result:
|
|
228
|
+
record[key] = result[key]
|
|
229
|
+
row["status"] = "verified"
|
|
230
|
+
row.pop("stale_checks", None)
|
|
231
|
+
row.pop("stale_reason", None)
|
|
232
|
+
return record, "verified"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _record_result(row: dict[str, object], result: Mapping[str, object], run_key: str,
|
|
236
|
+
executed_at: str, fingerprint: str) -> Optional[tuple[dict[str, object], str]]:
|
|
237
|
+
"""Apply one result to a manifest row; None means keep the row unchanged."""
|
|
238
|
+
state = str(result["status"])
|
|
239
|
+
if result.get("cache_reused") is True:
|
|
240
|
+
return _restore_checkpoint(row, result, run_key, executed_at, fingerprint)
|
|
241
|
+
# A deferral placeholder (e2e skipped in a non-E2E run) must never
|
|
242
|
+
# downgrade a verified terminal scenario: verify-e2e runs first, then
|
|
243
|
+
# later Done/finish passes would otherwise silently un-verify it.
|
|
244
|
+
if state == "e2e_deferred" and row.get("status") == "verified":
|
|
245
|
+
return None
|
|
246
|
+
# An unavailable environment is not new product evidence: keep a prior
|
|
247
|
+
# verified conclusion, but never promote an unverified row.
|
|
248
|
+
if state == "env_blocked" and row.get("status") == "verified":
|
|
249
|
+
return None
|
|
250
|
+
row["revision"] = int(row.get("revision", 0)) + 1
|
|
251
|
+
status = "verified" if state == "passed" else state
|
|
252
|
+
row["status"] = status
|
|
253
|
+
row.pop("stale_checks", None)
|
|
254
|
+
row.pop("stale_reason", None)
|
|
255
|
+
record = {**result, "revision": row["revision"], "run_id": run_key, "executed_at": executed_at}
|
|
256
|
+
# Identity for cross-run reuse checks: command/cwd/timeout + content
|
|
257
|
+
# fingerprint of the worktree the command actually ran against.
|
|
258
|
+
record["command"] = row.get("command")
|
|
259
|
+
record["cwd"] = row.get("cwd", ".")
|
|
260
|
+
record["timeout"] = row.get("timeout", 60)
|
|
261
|
+
if fingerprint and status == "verified":
|
|
262
|
+
record["fingerprint"] = fingerprint
|
|
263
|
+
return record, status
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def stale_scenario_checks(row: Mapping[str, object], hashes: Mapping[str, Mapping[str, object]],
|
|
267
|
+
fingerprint: str) -> list[str]:
|
|
268
|
+
"""Compare every declared check; a missing or unavailable hash is stale."""
|
|
269
|
+
if row.get("status") not in ("verified", "accepted_risk", "stale"):
|
|
270
|
+
return []
|
|
271
|
+
if row.get("kind") == "manual" or row.get("exec") in ("manual", "aspirational"):
|
|
272
|
+
return []
|
|
273
|
+
check_ids = scenario_check_ids(row)
|
|
274
|
+
evidence = row.get("evidence")
|
|
275
|
+
evidence = evidence if isinstance(evidence, Mapping) else {}
|
|
276
|
+
if not check_ids:
|
|
277
|
+
return [] if fingerprint and evidence.get("fingerprint") == fingerprint else [str(row["id"])]
|
|
278
|
+
stale = []
|
|
279
|
+
for check_id in check_ids:
|
|
280
|
+
expected = hashes.get(check_id, {}).get("input_hash")
|
|
281
|
+
actual = recorded_check_hash(row, check_id)
|
|
282
|
+
if not expected or expected != actual:
|
|
283
|
+
stale.append(check_id)
|
|
284
|
+
return stale
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def invalidate_manifest_evidence(root: Path, manifest: Path,
|
|
288
|
+
fingerprint: str) -> list[dict[str, object]]:
|
|
289
|
+
"""Mark outdated conclusions without changing historical execution evidence."""
|
|
290
|
+
from codeflow.acceptance.cf_check_inputs import check_input_hashes
|
|
291
|
+
from codeflow.core.cf_fs import atomic_write_text
|
|
292
|
+
data = load_manifest(manifest)
|
|
293
|
+
checks = {str(check["id"]): check for check in data.get("checks", [])}
|
|
294
|
+
hashes = check_input_hashes(str(root), checks, fingerprint)
|
|
295
|
+
changed: list[dict[str, object]] = []
|
|
296
|
+
for row in data["scenarios"]:
|
|
297
|
+
stale = stale_scenario_checks(row, hashes, fingerprint)
|
|
298
|
+
if not stale:
|
|
299
|
+
continue
|
|
300
|
+
row.update(status="stale", stale_checks=stale)
|
|
301
|
+
changed.append({"scenario_id": row["id"], "checks": stale})
|
|
302
|
+
if not changed:
|
|
303
|
+
return []
|
|
304
|
+
atomic_write_text(manifest, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
305
|
+
task_name = data.get("task_file")
|
|
306
|
+
if isinstance(task_name, str) and task_name:
|
|
307
|
+
task = (manifest.parent / task_name).resolve()
|
|
308
|
+
rows = {row["id"]: row for row in data["scenarios"]}
|
|
309
|
+
for item in changed:
|
|
310
|
+
row = rows[item["scenario_id"]]
|
|
311
|
+
sync_task_evidence(task, str(row["id"]), "runtime",
|
|
312
|
+
"input changed: " + ", ".join(item["checks"]),
|
|
313
|
+
str(row.get("owner", "")), "stale")
|
|
314
|
+
return changed
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def persist_results(path: Path, data: dict[str, object], results: Sequence[Mapping[str, object]],
|
|
318
|
+
fingerprint: str = "", run_id: str = "") -> None:
|
|
319
|
+
rows = {row["id"]: row for row in data["scenarios"]}
|
|
320
|
+
run_key, executed_at = run_id or uuid4().hex, datetime.now(timezone.utc).isoformat()
|
|
321
|
+
updates = []
|
|
322
|
+
for result in results:
|
|
323
|
+
state = str(result["status"])
|
|
324
|
+
if state in ("not_included", "manual_pending"):
|
|
325
|
+
continue
|
|
326
|
+
row = rows[result["id"]]
|
|
327
|
+
result_fingerprint = str(result.get("input_hash") or fingerprint)
|
|
328
|
+
applied = _record_result(row, result, run_key, executed_at, result_fingerprint)
|
|
329
|
+
if applied is None:
|
|
330
|
+
continue
|
|
331
|
+
record, status = applied
|
|
332
|
+
row["evidence"] = record
|
|
333
|
+
if result.get("cache_reused") is not True:
|
|
334
|
+
runs = row.setdefault("runs", [])
|
|
335
|
+
runs.append(record)
|
|
336
|
+
if len(runs) > 50:
|
|
337
|
+
del runs[:-50]
|
|
338
|
+
updates.append((row, record, status))
|
|
339
|
+
from codeflow.core.cf_fs import atomic_write_text
|
|
340
|
+
atomic_write_text(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
341
|
+
_store_evidence_runs(path, rows, results, run_key, executed_at, fingerprint)
|
|
342
|
+
task_name = data.get("task_file")
|
|
343
|
+
if isinstance(task_name, str) and task_name:
|
|
344
|
+
task = Path(task_name) if Path(task_name).is_absolute() else path.parent / task_name
|
|
345
|
+
for row, record, status in updates:
|
|
346
|
+
detail = f"automated command {record['status']}; run_id={run_key}"
|
|
347
|
+
sync_task_evidence(task, str(row["id"]), "runner", detail, str(row.get("owner", "")), status)
|