@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
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
"""Executable task handoffs used by every platform command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import fnmatch
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
from typing import IO, Mapping, Optional, Sequence
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from codeflow.cli.cf_acceptance_manifest import record_manual_evidence
|
|
18
|
+
from codeflow.acceptance.cf_acceptance_schema import load_manifest, verified_evidence
|
|
19
|
+
from codeflow.core.cf_exec_base import execution_session, redact_sensitive_payload, report_session, worktree_fingerprint
|
|
20
|
+
from codeflow.core import cf_git
|
|
21
|
+
from codeflow.cli.cf_spec_context import (ContextError, DEFAULT_ACTIVE_EXCLUDES, Decision, SpecContext,
|
|
22
|
+
apply_decision, load_active_task, load_context, save_context,
|
|
23
|
+
start_active_task)
|
|
24
|
+
from codeflow.spec.cf_stage_gate import evidence_deferral_kind
|
|
25
|
+
from codeflow.spec.cf_spec_context_model import context_sha256
|
|
26
|
+
from codeflow.task.cf_task_runtime import is_non_execution_error, run_done_gate
|
|
27
|
+
from codeflow.task.cf_requirement_verification import (
|
|
28
|
+
VERIFY_LAYERS, VERIFY_PROFILES, collect_requirement_bindings, collect_review_bindings,
|
|
29
|
+
demand_tasks as _demand_tasks, e2e_preflight as _e2e_preflight,
|
|
30
|
+
parse_verify_layers, parse_verify_profile, pending_manual_rules as _pending_manual_rules,
|
|
31
|
+
stale_functional_rows as _stale_functional_rows, verify_layers,
|
|
32
|
+
)
|
|
33
|
+
from codeflow.spec.cf_workflow_service import (FINISHED_STATUSES, require_active_task,
|
|
34
|
+
block_task, check_startable, complete_requirement_verification,
|
|
35
|
+
cleanup_session_projections, complete_task,
|
|
36
|
+
locate_task_file, provisional_task, read_task_section,
|
|
37
|
+
remove_session_projection, resume_task, task_truth_issues)
|
|
38
|
+
from codeflow.spec.cf_workflow_transaction import recover_transition
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _finish_task(root: str, directory: str, task_file: str, task_id: str) -> dict[str, object]:
|
|
42
|
+
require_active_task(root, task_id, directory, task_file)
|
|
43
|
+
gate = run_done_gate(root, directory, task_id=task_id)
|
|
44
|
+
if gate.decision == "provisional":
|
|
45
|
+
transition = provisional_task(root, directory, task_file, task_id, gate.message)
|
|
46
|
+
return {
|
|
47
|
+
"decision": "provisional",
|
|
48
|
+
"reason": gate.message,
|
|
49
|
+
"missing_checks": list(gate.missing_checks),
|
|
50
|
+
"next": gate.next_action,
|
|
51
|
+
"env_blocked_details": list(gate.env_blocked_details),
|
|
52
|
+
"deferred_review": gate.deferred_review,
|
|
53
|
+
"deferred_requirement": gate.deferred_requirement,
|
|
54
|
+
"deferred_budget": gate.deferred_budget,
|
|
55
|
+
"deferred_heavy": gate.deferred_heavy,
|
|
56
|
+
"evidence": gate.evidence,
|
|
57
|
+
**transition,
|
|
58
|
+
}
|
|
59
|
+
if gate.decision != "pass":
|
|
60
|
+
return {"decision": "block", "reason": gate.message, "evidence": gate.evidence,
|
|
61
|
+
"env_blocked_details": list(gate.env_blocked_details)}
|
|
62
|
+
completed = complete_task(root, directory, task_file, task_id, True)
|
|
63
|
+
# 完成后清理本任务的 Spec Session 投影(瞬时文件,避免往期规则被后续会话读到)
|
|
64
|
+
result = {
|
|
65
|
+
"decision": "pass",
|
|
66
|
+
"deferred_review": gate.deferred_review,
|
|
67
|
+
"deferred_requirement": gate.deferred_requirement,
|
|
68
|
+
"deferred_budget": gate.deferred_budget,
|
|
69
|
+
"deferred_heavy": gate.deferred_heavy,
|
|
70
|
+
"session_projection": remove_session_projection(root, task_file),
|
|
71
|
+
**completed,
|
|
72
|
+
}
|
|
73
|
+
hints = []
|
|
74
|
+
deferred_total = gate.deferred_review + gate.deferred_requirement + gate.deferred_budget
|
|
75
|
+
if deferred_total:
|
|
76
|
+
hints.append(f"{deferred_total} verifier(s) deferred; run cf-task:verify-e2e for the requirement directory")
|
|
77
|
+
if gate.deferred_heavy:
|
|
78
|
+
hints.append(f"{gate.deferred_heavy} heavy validator(s) deferred; archive / cf-validate runs them")
|
|
79
|
+
if hints:
|
|
80
|
+
result["deferred_hint"] = " | ".join(hints)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _finish_report(reports: Sequence[Mapping[str, object]], started: float) -> dict[str, object]:
|
|
85
|
+
acceptance = next((item for item in reports if item.get("kind") == "acceptance"), {})
|
|
86
|
+
timings = {key: 0.0 for key in ("planning_ms", "preflight_ms", "execution_ms", "evidence_ms")}
|
|
87
|
+
for report in reports:
|
|
88
|
+
values = report.get("timings") or {}
|
|
89
|
+
for key in timings:
|
|
90
|
+
timings[key] += float(values.get(key, 0))
|
|
91
|
+
if report.get("kind") == "spec_check":
|
|
92
|
+
timings["execution_ms"] += float(report.get("execution_ms", 0))
|
|
93
|
+
timings["elapsed_ms"] = (time.monotonic() - started) * 1000
|
|
94
|
+
scopes = list(dict.fromkeys("spec" if r.get("kind") in ("spec_check", "spec_phases")
|
|
95
|
+
else str(r.get("kind")) for r in reports))
|
|
96
|
+
identities = [str(identity) for report in reports for identity in report.get("execution_identities", [])]
|
|
97
|
+
identities.extend(str(report["identity"]) for report in reports if report.get("kind") == "spec_check" and report.get("identity"))
|
|
98
|
+
identities.sort()
|
|
99
|
+
reused = sum(int(report.get("reused", 0)) for report in reports)
|
|
100
|
+
executed = sum(int(report.get("executed", 0)) for report in reports if report.get("kind") == "acceptance")
|
|
101
|
+
executed += sum(max(0, int(report.get("selected", 0)) - int(report.get("reused", 0)))
|
|
102
|
+
for report in reports if report.get("kind") == "validators")
|
|
103
|
+
executed += sum(1 for report in reports if report.get("kind") == "spec_check" and not report.get("reused"))
|
|
104
|
+
return {"selection_reason": acceptance.get("selection_reason", "validator_triggers"),
|
|
105
|
+
"scope": {"task": acceptance.get("scope", {}), "layers": [r.get("kind") for r in reports]},
|
|
106
|
+
"reuse_reason": "evidence_reused" if reused else "no_reused_results", "executed": executed,
|
|
107
|
+
"reused": reused, "execution_identities": identities,
|
|
108
|
+
"input_change_categories": [{"layer": r.get("kind"), "categories": r["input_change_category"]}
|
|
109
|
+
for r in reports if "input_change_category" in r],
|
|
110
|
+
"summary": f"finish checks: executed {executed}, reused {reused}",
|
|
111
|
+
"timings": timings, "timings_scope": scopes, "layers": list(reports)}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def finish_task(root: str, directory: str, task_file: str, task_id: str) -> dict[str, object]:
|
|
115
|
+
started = time.monotonic()
|
|
116
|
+
with report_session() as reports:
|
|
117
|
+
result = _finish_task(root, directory, task_file, task_id)
|
|
118
|
+
if reports:
|
|
119
|
+
result["verification"] = _finish_report(reports, started)
|
|
120
|
+
result.update(_verification_reporting(result["verification"], started))
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_CAPABILITY_LINE = re.compile(r"^- \*\*(?:Capability|Demo)\*\*:[ \t]*(.*)$")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _normalize_capability(value: str) -> str:
|
|
128
|
+
return " ".join(value.split()).casefold()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _task_capability(task_file: Path, task_id: str) -> str:
|
|
132
|
+
"""Read the user-demonstrable capability declared by one TASK section."""
|
|
133
|
+
try:
|
|
134
|
+
section = read_task_section(str(task_file), task_id)
|
|
135
|
+
except ValueError:
|
|
136
|
+
return ""
|
|
137
|
+
for line in section.splitlines():
|
|
138
|
+
match = _CAPABILITY_LINE.match(line)
|
|
139
|
+
if match:
|
|
140
|
+
return " ".join(match.group(1).split())
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _unique_ids(values: Sequence[str]) -> list[str]:
|
|
145
|
+
seen: set[str] = set()
|
|
146
|
+
chosen: list[str] = []
|
|
147
|
+
for value in values:
|
|
148
|
+
task_id = value.strip()
|
|
149
|
+
if task_id and task_id not in seen:
|
|
150
|
+
seen.add(task_id)
|
|
151
|
+
chosen.append(task_id)
|
|
152
|
+
return chosen
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _batch_selection(directory: Path, tasks: Sequence[str],
|
|
156
|
+
capability: str) -> tuple[list[tuple[Path, str, str]], str]:
|
|
157
|
+
"""Resolve --batch IDs or a --capability name to TASK file/status tuples."""
|
|
158
|
+
available = _demand_tasks(directory)
|
|
159
|
+
by_id = {task_id: (task_file, status) for task_file, task_id, status in available}
|
|
160
|
+
if capability.strip():
|
|
161
|
+
wanted = _normalize_capability(capability)
|
|
162
|
+
selected = [
|
|
163
|
+
(task_file, task_id, status)
|
|
164
|
+
for task_file, task_id, status in available
|
|
165
|
+
if _normalize_capability(_task_capability(task_file, task_id)) == wanted
|
|
166
|
+
]
|
|
167
|
+
if not selected:
|
|
168
|
+
known = sorted({_task_capability(f, t) for f, t, _ in available} - {""})
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"unknown_capability: {capability.strip()}; available: {', '.join(known) or 'none'}"
|
|
171
|
+
)
|
|
172
|
+
return selected, "capability"
|
|
173
|
+
chosen = _unique_ids(tasks)
|
|
174
|
+
if not chosen:
|
|
175
|
+
raise ValueError("batch_empty: pass --batch TASK-... or --capability <name>")
|
|
176
|
+
unknown = [task_id for task_id in chosen if task_id not in by_id]
|
|
177
|
+
if unknown:
|
|
178
|
+
raise ValueError(f"unknown_tasks: {', '.join(unknown)}")
|
|
179
|
+
return [(by_id[task_id][0], task_id, by_id[task_id][1]) for task_id in chosen], "batch"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _batch_working_changes(root: str) -> tuple[str, ...]:
|
|
183
|
+
"""Current business changes, ignoring the paths the active marker excludes."""
|
|
184
|
+
return tuple(sorted(
|
|
185
|
+
path for path in cf_git.changed_files(root)
|
|
186
|
+
if not any(fnmatch.fnmatchcase(path, pattern) for pattern in DEFAULT_ACTIVE_EXCLUDES)
|
|
187
|
+
))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _batch_active_task(root: str) -> str:
|
|
191
|
+
if not (Path(root) / ".code-flow/.active-task.json").exists():
|
|
192
|
+
return ""
|
|
193
|
+
return load_active_task(root).task_id
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _ensure_batch_marker(root: str, directory: str, task_id: str) -> bool:
|
|
197
|
+
"""Open a transient marker for one batch task; returns True when created.
|
|
198
|
+
|
|
199
|
+
A pre-existing marker is only accepted for the same TASK. Other TASKs are
|
|
200
|
+
rejected: silently switching identity would let a batch finish arbitrary
|
|
201
|
+
work that was never started.
|
|
202
|
+
"""
|
|
203
|
+
marker = Path(root) / ".code-flow/.active-task.json"
|
|
204
|
+
if marker.exists():
|
|
205
|
+
active = load_active_task(root)
|
|
206
|
+
if active.task_id != task_id:
|
|
207
|
+
raise ValueError(f"active_mismatch: marker={active.task_id} requested={task_id}")
|
|
208
|
+
if active.status != "active":
|
|
209
|
+
raise ValueError(f"active_not_ready: {active.task_id} is {active.status}")
|
|
210
|
+
return False
|
|
211
|
+
context = load_context(str(Path(directory) / "spec-context.yml"))
|
|
212
|
+
start_active_task(root, directory, task_id, context_sha256(context), _batch_working_changes(root))
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _abandon_batch_marker(root: str) -> bool:
|
|
217
|
+
"""Drop a batch marker without changing the Markdown view.
|
|
218
|
+
|
|
219
|
+
A failed TASK must stay open (draft/in-progress) and must not claim done;
|
|
220
|
+
the marker only holds the single active slot, so it cannot be left behind
|
|
221
|
+
while the rest of the batch continues.
|
|
222
|
+
"""
|
|
223
|
+
marker = Path(root) / ".code-flow/.active-task.json"
|
|
224
|
+
if not marker.exists():
|
|
225
|
+
return False
|
|
226
|
+
from codeflow.cli.cf_spec_context import doctor_active_task
|
|
227
|
+
|
|
228
|
+
doctor_active_task(root, "", abandon=True)
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _task_evidence_counts(evidence: Sequence[Mapping[str, object]]) -> tuple[int, int]:
|
|
233
|
+
executed = reused = 0
|
|
234
|
+
for item in evidence:
|
|
235
|
+
if evidence_deferral_kind(item) in ("review_stage", "heavy"):
|
|
236
|
+
continue
|
|
237
|
+
if is_non_execution_error(item.get("error_code")):
|
|
238
|
+
continue
|
|
239
|
+
details = item.get("details")
|
|
240
|
+
details = details if isinstance(details, Mapping) else {}
|
|
241
|
+
if details.get("cache_reused"):
|
|
242
|
+
reused += 1
|
|
243
|
+
else:
|
|
244
|
+
executed += 1
|
|
245
|
+
return executed, reused
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _acceptance_task_evidence(directory: str, task_id: str) -> list[dict[str, object]]:
|
|
249
|
+
"""Project the TASK's persisted manifest rows into per-check evidence."""
|
|
250
|
+
manifest = Path(directory) / ".acceptance-manifest.json"
|
|
251
|
+
if not manifest.is_file():
|
|
252
|
+
return []
|
|
253
|
+
try:
|
|
254
|
+
data = load_manifest(manifest)
|
|
255
|
+
except (OSError, ValueError):
|
|
256
|
+
return []
|
|
257
|
+
items: list[dict[str, object]] = []
|
|
258
|
+
for row in data.get("scenarios") or ():
|
|
259
|
+
if not isinstance(row, Mapping):
|
|
260
|
+
continue
|
|
261
|
+
owner = row.get("owner")
|
|
262
|
+
if isinstance(owner, str) and owner and owner != task_id:
|
|
263
|
+
continue
|
|
264
|
+
record = row.get("evidence")
|
|
265
|
+
if not isinstance(record, Mapping):
|
|
266
|
+
continue
|
|
267
|
+
scenario_id = str(row.get("id", ""))
|
|
268
|
+
check_id = str(row.get("check") or scenario_id)
|
|
269
|
+
verified = row.get("status") == "verified"
|
|
270
|
+
items.append({
|
|
271
|
+
"verifier_ref": check_id,
|
|
272
|
+
"check_id": check_id,
|
|
273
|
+
"status": "verified" if verified else "unverified",
|
|
274
|
+
"error_code": None if verified else "acceptance_failed",
|
|
275
|
+
"details": {"scenario": scenario_id, "cache_reused": bool(record.get("cache_reused"))},
|
|
276
|
+
})
|
|
277
|
+
return items
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _finish_batch_task(root: str, directory: str, task_file: str, task_id: str) -> dict[str, object]:
|
|
281
|
+
"""Run one TASK inside the shared batch execution session."""
|
|
282
|
+
activated = _ensure_batch_marker(root, directory, task_id)
|
|
283
|
+
try:
|
|
284
|
+
gate = run_done_gate(root, directory, task_id=task_id)
|
|
285
|
+
evidence = [*gate.evidence, *_acceptance_task_evidence(directory, task_id)]
|
|
286
|
+
executed, reused = _task_evidence_counts(evidence)
|
|
287
|
+
if gate.decision == "pass":
|
|
288
|
+
completed = complete_task(root, directory, task_file, task_id, True)
|
|
289
|
+
result = {
|
|
290
|
+
**completed,
|
|
291
|
+
"task": task_id,
|
|
292
|
+
"decision": "pass",
|
|
293
|
+
"status": "done",
|
|
294
|
+
"activated": activated,
|
|
295
|
+
"executed": executed,
|
|
296
|
+
"reused": reused,
|
|
297
|
+
"evidence": evidence,
|
|
298
|
+
"session_projection": remove_session_projection(root, task_file),
|
|
299
|
+
}
|
|
300
|
+
return result
|
|
301
|
+
result = {
|
|
302
|
+
"task": task_id,
|
|
303
|
+
"decision": gate.decision,
|
|
304
|
+
"status": "unchanged",
|
|
305
|
+
"reason": gate.message,
|
|
306
|
+
"activated": activated,
|
|
307
|
+
"executed": executed,
|
|
308
|
+
"reused": reused,
|
|
309
|
+
"missing_checks": list(gate.missing_checks),
|
|
310
|
+
"next": gate.next_action,
|
|
311
|
+
"evidence": evidence,
|
|
312
|
+
}
|
|
313
|
+
result["marker_cleared"] = _abandon_batch_marker(root)
|
|
314
|
+
return result
|
|
315
|
+
except Exception:
|
|
316
|
+
_abandon_batch_marker(root)
|
|
317
|
+
raise
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _aggregate_batch_checks(
|
|
321
|
+
results: Sequence[Mapping[str, object]],
|
|
322
|
+
) -> tuple[list[dict[str, object]], int, int]:
|
|
323
|
+
"""Fold per-task evidence into one entry per check (executed once, reused N)."""
|
|
324
|
+
checks: dict[str, dict[str, object]] = {}
|
|
325
|
+
order: list[str] = []
|
|
326
|
+
for result in results:
|
|
327
|
+
task = str(result.get("task", ""))
|
|
328
|
+
evidence = result.get("evidence")
|
|
329
|
+
if not isinstance(evidence, Sequence):
|
|
330
|
+
continue
|
|
331
|
+
for item in evidence:
|
|
332
|
+
if not isinstance(item, Mapping):
|
|
333
|
+
continue
|
|
334
|
+
kind = evidence_deferral_kind(item)
|
|
335
|
+
if kind in ("review_stage", "heavy"):
|
|
336
|
+
continue
|
|
337
|
+
check_id = str(item.get("verifier_ref") or item.get("check_id") or "")
|
|
338
|
+
if not check_id:
|
|
339
|
+
continue
|
|
340
|
+
entry = checks.get(check_id)
|
|
341
|
+
if entry is None:
|
|
342
|
+
entry = {"check_id": check_id, "status": "verified", "tasks": [],
|
|
343
|
+
"executions": 0, "reused": 0}
|
|
344
|
+
checks[check_id] = entry
|
|
345
|
+
order.append(check_id)
|
|
346
|
+
if task and task not in entry["tasks"]:
|
|
347
|
+
entry["tasks"].append(task)
|
|
348
|
+
if item.get("status") != "verified":
|
|
349
|
+
entry["status"] = "failed"
|
|
350
|
+
if is_non_execution_error(item.get("error_code")):
|
|
351
|
+
continue
|
|
352
|
+
if entry["executions"] == 0:
|
|
353
|
+
entry["executions"] = 1
|
|
354
|
+
else:
|
|
355
|
+
entry["reused"] = int(entry["reused"]) + 1
|
|
356
|
+
aggregated = [checks[check_id] for check_id in order]
|
|
357
|
+
executed = sum(1 for item in aggregated if int(item["executions"]) > 0)
|
|
358
|
+
reused = sum(int(item["reused"]) for item in aggregated)
|
|
359
|
+
return aggregated, executed, reused
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _run_batch_tasks(root: str, directory: str,
|
|
363
|
+
selected: Sequence[tuple[Path, str, str]]) -> list[dict[str, object]]:
|
|
364
|
+
results: list[dict[str, object]] = []
|
|
365
|
+
with execution_session():
|
|
366
|
+
for task_file, task_id, status in selected:
|
|
367
|
+
if status in FINISHED_STATUSES:
|
|
368
|
+
results.append({"task": task_id, "decision": "pass", "status": "already_done",
|
|
369
|
+
"reason": "already_done", "activated": False,
|
|
370
|
+
"executed": 0, "reused": 0, "evidence": []})
|
|
371
|
+
continue
|
|
372
|
+
blockers = check_startable(str(task_file), task_id)
|
|
373
|
+
if blockers:
|
|
374
|
+
results.append({"task": task_id, "decision": "block", "status": status,
|
|
375
|
+
"reason": "start_blocked", "blockers": blockers,
|
|
376
|
+
"activated": False, "executed": 0, "reused": 0, "evidence": []})
|
|
377
|
+
continue
|
|
378
|
+
try:
|
|
379
|
+
results.append(_finish_batch_task(root, directory, str(task_file), task_id))
|
|
380
|
+
except (OSError, ValueError) as exc:
|
|
381
|
+
results.append({"task": task_id, "decision": "block", "status": status,
|
|
382
|
+
"reason": "batch_task_error", "error": str(exc),
|
|
383
|
+
"activated": False, "executed": 0, "reused": 0, "evidence": []})
|
|
384
|
+
return results
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _batch_payload(results: Sequence[Mapping[str, object]], selected: Sequence[tuple[Path, str, str]],
|
|
388
|
+
mode: str, capability: str) -> dict[str, object]:
|
|
389
|
+
checks, executed, reused = _aggregate_batch_checks(results)
|
|
390
|
+
decisions = {str(item.get("decision")) for item in results}
|
|
391
|
+
decision = "block" if "block" in decisions else "provisional" if "provisional" in decisions else "pass"
|
|
392
|
+
failed = [str(item["task"]) for item in results if item.get("decision") == "block"]
|
|
393
|
+
first_reason = next(
|
|
394
|
+
(str(item.get("reason", "")) for item in results if item.get("decision") != "pass"), ""
|
|
395
|
+
)
|
|
396
|
+
return {
|
|
397
|
+
"decision": decision,
|
|
398
|
+
"action": "finish-batch",
|
|
399
|
+
"reason": "batch_failed: " + ", ".join(failed) if failed else first_reason,
|
|
400
|
+
"selection": {"mode": mode, "tasks": [item[1] for item in selected],
|
|
401
|
+
"capability": capability.strip() or None},
|
|
402
|
+
"results": list(results),
|
|
403
|
+
"evidence": checks,
|
|
404
|
+
"executed": executed,
|
|
405
|
+
"reused": reused,
|
|
406
|
+
"failed": failed,
|
|
407
|
+
"env_blocked": sum(
|
|
408
|
+
1 for item in results if str(item.get("reason", "")).startswith("env")
|
|
409
|
+
),
|
|
410
|
+
"incomplete": sum(1 for item in results if item.get("decision") == "provisional"),
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def finish_batch(root: str, directory: str, tasks: Sequence[str] = (),
|
|
415
|
+
capability: str = "") -> dict[str, object]:
|
|
416
|
+
"""Batch Done Gate: one shared execution session for a TASK list/capability.
|
|
417
|
+
|
|
418
|
+
Every selected TASK is gated independently, so one failure does not mark
|
|
419
|
+
the others done or block their completion. Code checks shared by the batch
|
|
420
|
+
execute once inside the session; their evidence is folded per check.
|
|
421
|
+
"""
|
|
422
|
+
recover_transition(root)
|
|
423
|
+
selected, mode = _batch_selection(Path(directory), tasks, capability)
|
|
424
|
+
active_id = _batch_active_task(root)
|
|
425
|
+
selected_ids = [task_id for _, task_id, _ in selected]
|
|
426
|
+
if active_id and active_id not in selected_ids:
|
|
427
|
+
return {
|
|
428
|
+
"decision": "block",
|
|
429
|
+
"action": "finish-batch",
|
|
430
|
+
"reason": "active_mismatch",
|
|
431
|
+
"detail": f"marker={active_id}; batch={','.join(selected_ids)}",
|
|
432
|
+
"results": [],
|
|
433
|
+
}
|
|
434
|
+
# The active TASK must be consumed before any transient marker is opened.
|
|
435
|
+
selected.sort(key=lambda item: 0 if item[1] == active_id else 1)
|
|
436
|
+
results = _run_batch_tasks(root, directory, selected)
|
|
437
|
+
payload = _batch_payload(results, selected, mode, capability)
|
|
438
|
+
completed = [str(item["task"]) for item in results if item.get("decision") == "pass"]
|
|
439
|
+
if completed and (Path(directory) / ".acceptance-manifest.json").is_file():
|
|
440
|
+
smoke = verify_layers(root, directory, layers="validators,e2e", profile="smoke", only_tasks=completed)
|
|
441
|
+
payload["batch_smoke"] = smoke
|
|
442
|
+
if smoke.get("decision") != "pass":
|
|
443
|
+
payload["decision"] = "block"
|
|
444
|
+
payload["reason"] = "batch_smoke_failed"
|
|
445
|
+
return payload
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _complete_requirement_verification(root: str, directory: str,
|
|
449
|
+
tasks: Sequence[tuple[Path, str, str]],
|
|
450
|
+
result: dict[str, object]) -> dict[str, object]:
|
|
451
|
+
return complete_requirement_verification(root, directory, tasks, result)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def verify_e2e(root: str, directory: str, layers: Optional[str] = None,
|
|
455
|
+
profile: Optional[str] = None, resume: bool = True,
|
|
456
|
+
deadline: Optional[float] = None, max_failures: int = 0,
|
|
457
|
+
fail_fast: bool = False, jobs: int = 0) -> dict[str, object]:
|
|
458
|
+
"""Legacy alias: all layers under the explicit full profile."""
|
|
459
|
+
result = verify_layers(root, directory, layers or ",".join(VERIFY_LAYERS), profile or "full",
|
|
460
|
+
resume=resume, deadline=deadline, max_failures=max_failures,
|
|
461
|
+
fail_fast=fail_fast, jobs=jobs)
|
|
462
|
+
layer_map = result.get("layers", {})
|
|
463
|
+
if "review" in layer_map:
|
|
464
|
+
review = layer_map["review"]
|
|
465
|
+
result["review"] = review.get("review", {
|
|
466
|
+
"executed": review.get("executed", 0),
|
|
467
|
+
"reused": review.get("reused", 0),
|
|
468
|
+
"failed": review.get("failed", []),
|
|
469
|
+
})
|
|
470
|
+
result["executed"] = review.get("executed", 0)
|
|
471
|
+
result["reused"] = review.get("reused", 0)
|
|
472
|
+
result["failed"] = review.get("failed", [])
|
|
473
|
+
if "validators" in layer_map:
|
|
474
|
+
result["validation"] = layer_map["validators"].get("validation", {
|
|
475
|
+
"decision": layer_map["validators"].get("decision"),
|
|
476
|
+
"reason": layer_map["validators"].get("reason", ""),
|
|
477
|
+
"reused": layer_map["validators"].get("reused", 0),
|
|
478
|
+
"failures": layer_map["validators"].get("failed", []),
|
|
479
|
+
})
|
|
480
|
+
if "e2e" in layer_map:
|
|
481
|
+
for key in ("results", "checks", "error"):
|
|
482
|
+
if key in layer_map["e2e"]:
|
|
483
|
+
result[key] = layer_map["e2e"][key]
|
|
484
|
+
return result
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _archived_risks(directory: Path) -> list[dict[str, object]]:
|
|
488
|
+
"""Accepted risks are archived distinctly from verified evidence."""
|
|
489
|
+
manifest = directory / ".acceptance-manifest.json"
|
|
490
|
+
if not manifest.is_file():
|
|
491
|
+
return []
|
|
492
|
+
try:
|
|
493
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
494
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
495
|
+
return []
|
|
496
|
+
risks = []
|
|
497
|
+
for check in data.get("checks", []) if isinstance(data.get("checks"), list) else []:
|
|
498
|
+
if isinstance(check, Mapping) and isinstance(check.get("accepted_risk"), Mapping):
|
|
499
|
+
risks.append({"check_id": check.get("id"), **dict(check["accepted_risk"])})
|
|
500
|
+
return risks
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _verification_reporting(verification: Mapping[str, object], started: float) -> dict[str, object]:
|
|
504
|
+
fields = ("selection_reason", "selection_reasons", "reuse_reason", "scope", "summary", "executed", "reused",
|
|
505
|
+
"execution_identities", "input_change_category", "input_change_categories", "timings_scope")
|
|
506
|
+
report = {key: verification[key] for key in fields if key in verification}
|
|
507
|
+
if isinstance(verification.get("timings"), Mapping):
|
|
508
|
+
report["timings"] = {**verification["timings"], "elapsed_ms": (time.monotonic() - started) * 1000}
|
|
509
|
+
return report
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _archive_documents(root: str, directory: Path) -> dict[str, str]:
|
|
513
|
+
"""Include workflow inputs that the worktree fingerprint intentionally omits."""
|
|
514
|
+
base = Path(root)
|
|
515
|
+
spec_root = base / ".code-flow/specs"
|
|
516
|
+
candidates = [base / ".code-flow/config.yml", base / ".code-flow/validation.yml"]
|
|
517
|
+
candidates.extend(path for path in spec_root.rglob("*.md") if "_session" not in path.parts)
|
|
518
|
+
candidates.extend(path for path in directory.rglob("*.md")
|
|
519
|
+
if path.name.endswith((".design.md", ".prd.md", ".scenarios.md")))
|
|
520
|
+
return {str(path.relative_to(base)): hashlib.sha256(path.read_bytes()).hexdigest()
|
|
521
|
+
for path in sorted(candidates) if path.is_file()}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _archive_snapshot(root: str, directory: Path) -> str:
|
|
525
|
+
"""Capture immutable plan identity and worktree inputs before moving."""
|
|
526
|
+
from codeflow.acceptance.cf_acceptance_manifest_core import IMMUTABLE_SCENARIO_FIELDS, validate_execution_baseline
|
|
527
|
+
fingerprint = worktree_fingerprint(root)
|
|
528
|
+
if not fingerprint or not directory.is_dir():
|
|
529
|
+
raise ValueError("archive_snapshot_unavailable")
|
|
530
|
+
contexts = {str(path.relative_to(directory)): context_sha256(load_context(str(path)))
|
|
531
|
+
for path in sorted(directory.rglob("spec-context.yml"))}
|
|
532
|
+
manifest = directory / ".acceptance-manifest.json"
|
|
533
|
+
plan: object = None
|
|
534
|
+
if manifest.is_file():
|
|
535
|
+
data = load_manifest(manifest)
|
|
536
|
+
validate_execution_baseline(manifest, data)
|
|
537
|
+
plan = {"task_sha256": data.get("task_sha256"), "checks": data.get("checks"),
|
|
538
|
+
"scenarios": [{key: row.get(key) for key in IMMUTABLE_SCENARIO_FIELDS}
|
|
539
|
+
for row in data["scenarios"]]}
|
|
540
|
+
payload = json.dumps([fingerprint, contexts, plan, _archive_documents(root, directory)],
|
|
541
|
+
ensure_ascii=False, sort_keys=True, default=str)
|
|
542
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def archive_requirement(root: str, directory: str) -> dict[str, object]:
|
|
546
|
+
"""Runtime archive gate: full verify first, then move the requirement dir."""
|
|
547
|
+
from datetime import date
|
|
548
|
+
from pathlib import Path as _Path
|
|
549
|
+
|
|
550
|
+
started = time.monotonic()
|
|
551
|
+
try:
|
|
552
|
+
snapshot = _archive_snapshot(root, Path(directory))
|
|
553
|
+
except (OSError, ValueError) as exc:
|
|
554
|
+
return {"decision": "block", "reason": "archive_snapshot_unavailable", "detail": str(exc)}
|
|
555
|
+
result = verify_layers(root, directory, layers=",".join(VERIFY_LAYERS), profile="full")
|
|
556
|
+
if result.get("decision") != "pass":
|
|
557
|
+
return {"decision": "block", "reason": "archive_verify_failed", "verify": result,
|
|
558
|
+
**_verification_reporting(result, started)}
|
|
559
|
+
source = _Path(directory)
|
|
560
|
+
try:
|
|
561
|
+
if snapshot != _archive_snapshot(root, source):
|
|
562
|
+
return {"decision": "block", "reason": "archive_snapshot_changed", "verify": result}
|
|
563
|
+
except (OSError, ValueError) as exc:
|
|
564
|
+
return {"decision": "block", "reason": "archive_snapshot_unavailable", "detail": str(exc), "verify": result}
|
|
565
|
+
accepted_risks = _archived_risks(source)
|
|
566
|
+
base = _Path(root) / ".code-flow/tasks/archived" / date.today().isoformat()
|
|
567
|
+
target = base / source.name
|
|
568
|
+
suffix = 2
|
|
569
|
+
while target.exists():
|
|
570
|
+
target = base / f"{source.name}-{suffix}"
|
|
571
|
+
suffix += 1
|
|
572
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
573
|
+
shutil.move(str(source), str(target))
|
|
574
|
+
result = {"decision": "pass", "archived_to": str(target), "verify": result,
|
|
575
|
+
**_verification_reporting(result, started)}
|
|
576
|
+
if accepted_risks:
|
|
577
|
+
result["accepted_risks"] = accepted_risks
|
|
578
|
+
return result
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def confirm_manual(root: str, directory: str, refs: Sequence[str], scenarios: Sequence[str],
|
|
582
|
+
confirmed_by: str, source: str, reason: str = "", evidence: str = "") -> dict[str, object]:
|
|
583
|
+
"""用户确认后批量写入 manual 验收记录(review 层 spec 规则 + manifest 场景)。
|
|
584
|
+
|
|
585
|
+
只有用户明确确认后才能调用;Agent 不得代确认(agent 身份会被拒绝)。
|
|
586
|
+
"""
|
|
587
|
+
owner = confirmed_by.strip()
|
|
588
|
+
if not owner:
|
|
589
|
+
raise ValueError("confirm-manual 需要 --confirmed-by <用户身份>")
|
|
590
|
+
if not source.strip():
|
|
591
|
+
raise ValueError("confirm-manual 需要 --source <用户回复原文>")
|
|
592
|
+
targets = collect_review_bindings(Path(directory))
|
|
593
|
+
pending_rules, confirmations = _pending_manual_rules(root, targets)
|
|
594
|
+
known = {str(item["ref"]) for item in pending_rules}
|
|
595
|
+
for spec_id, by_rule in confirmations.items():
|
|
596
|
+
known.update(f"{spec_id}#{rule_ref}" for rule_ref in by_rule)
|
|
597
|
+
chosen = [ref.strip() for ref in refs if ref.strip()] or sorted(str(item["ref"]) for item in pending_rules)
|
|
598
|
+
unknown = sorted(ref for ref in chosen if ref not in known)
|
|
599
|
+
if unknown:
|
|
600
|
+
raise ValueError(f"unknown_or_not_review_manual_ref: {', '.join(unknown)}")
|
|
601
|
+
reason_text = reason.strip() or "需求级人工验收确认"
|
|
602
|
+
confirmed_at = datetime.now(timezone.utc).isoformat()
|
|
603
|
+
records: dict[Path, SpecContext] = {}
|
|
604
|
+
for ref in chosen:
|
|
605
|
+
spec_id, rule_ref = ref.split("#", 1)
|
|
606
|
+
for ctx_path, _ in targets[spec_id]["rules"][rule_ref]:
|
|
607
|
+
context = records.get(ctx_path) or load_context(str(ctx_path))
|
|
608
|
+
decision = Decision("manual_verification", reason_text, owner, confirmed_at, source.strip(), None)
|
|
609
|
+
records[ctx_path] = apply_decision(context, spec_id, rule_ref, "review", decision)
|
|
610
|
+
for ctx_path in sorted(records, key=str):
|
|
611
|
+
save_context(str(ctx_path), records[ctx_path])
|
|
612
|
+
recorded_scenarios: list[str] = []
|
|
613
|
+
manifest = Path(directory) / ".acceptance-manifest.json"
|
|
614
|
+
if manifest.is_file():
|
|
615
|
+
data = load_manifest(manifest)
|
|
616
|
+
rows = {str(row.get("id")): row for row in data.get("scenarios", [])}
|
|
617
|
+
if scenarios:
|
|
618
|
+
chosen_scenarios = [sid.strip() for sid in scenarios if sid.strip()]
|
|
619
|
+
else:
|
|
620
|
+
chosen_scenarios = [sid for sid, row in rows.items()
|
|
621
|
+
if row.get("kind") == "manual" and not verified_evidence(row)]
|
|
622
|
+
for sid in chosen_scenarios:
|
|
623
|
+
row = rows.get(sid)
|
|
624
|
+
if row is None or (row.get("kind") != "manual" and row.get("exec") != "manual"):
|
|
625
|
+
raise ValueError(f"unknown_manual_scenario: {sid}")
|
|
626
|
+
if verified_evidence(row):
|
|
627
|
+
continue
|
|
628
|
+
record_manual_evidence(str(manifest), sid, owner, evidence.strip() or source.strip())
|
|
629
|
+
recorded_scenarios.append(sid)
|
|
630
|
+
if not chosen and not recorded_scenarios:
|
|
631
|
+
raise ValueError("no_pending_manual_confirmation")
|
|
632
|
+
return {
|
|
633
|
+
"ok": True,
|
|
634
|
+
"rules": chosen,
|
|
635
|
+
"scenarios": recorded_scenarios,
|
|
636
|
+
"confirmed_by": owner,
|
|
637
|
+
"confirmed_at": confirmed_at,
|
|
638
|
+
"note": "确认已写入 spec-context / manifest;重跑 verify-e2e 完成终验",
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _verify_layers_argument(layers: str, checks: bool) -> Optional[str]:
|
|
643
|
+
"""Map the cf-validate compatible ``--checks`` flag onto validators only."""
|
|
644
|
+
if not checks:
|
|
645
|
+
return layers or None
|
|
646
|
+
if layers and parse_verify_layers(layers) != ("validators",):
|
|
647
|
+
raise ValueError("--checks is an alias for --layers validators; pass one of them")
|
|
648
|
+
return "validators"
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _verification_arguments(parser: argparse.ArgumentParser, action: str) -> None:
|
|
652
|
+
parser.add_argument("--layers", default="", help="verify layers: validators,review,e2e (default all)")
|
|
653
|
+
parser.add_argument("--checks", action="store_true",
|
|
654
|
+
help="alias for --layers validators (cf-validate compatible entry)")
|
|
655
|
+
default_profile = "full" if action == "verify-e2e" else "changed"
|
|
656
|
+
parser.add_argument("--profile", default="", choices=VERIFY_PROFILES,
|
|
657
|
+
help=f"verify profile (default {default_profile})")
|
|
658
|
+
parser.add_argument("--resume", action="store_true", default=False, help="resume from the first unfinished check")
|
|
659
|
+
parser.add_argument("--deadline", type=float, default=0.0, help="deadline in seconds from now (0 = unbounded)")
|
|
660
|
+
parser.add_argument("--max-failures", type=int, default=0, help="stop after N failed checks (0 = unlimited)")
|
|
661
|
+
parser.add_argument("--fail-fast", action="store_true", default=False, help="stop after the first failed check")
|
|
662
|
+
parser.add_argument("--jobs", type=int, default=0, help="parallel check workers (0 = automatic)")
|
|
663
|
+
if action == "verify":
|
|
664
|
+
parser.add_argument("--tasks", default="", help="verify selected completed TASKs, comma-separated")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _workflow_parser() -> argparse.ArgumentParser:
|
|
668
|
+
parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND") or None,
|
|
669
|
+
allow_abbrev=False)
|
|
670
|
+
actions = parser.add_subparsers(dest="action", required=True)
|
|
671
|
+
for name in ("finish", "block", "resume", "verify", "verify-e2e",
|
|
672
|
+
"confirm-manual", "cleanup-session", "archive"):
|
|
673
|
+
action = actions.add_parser(name, allow_abbrev=False)
|
|
674
|
+
action.add_argument("--root", default=os.getcwd())
|
|
675
|
+
action.add_argument("--task-dir", required=True)
|
|
676
|
+
action.add_argument("--json", action="store_true")
|
|
677
|
+
if name in ("verify", "verify-e2e"):
|
|
678
|
+
_verification_arguments(action, name)
|
|
679
|
+
if name in ("finish", "block", "resume"):
|
|
680
|
+
action.add_argument("--task", default="")
|
|
681
|
+
if name == "finish":
|
|
682
|
+
action.add_argument("--batch", default="", help="finish multiple TASKs: TASK-001,TASK-002")
|
|
683
|
+
action.add_argument("--capability", default="", help="finish TASKs whose Capability matches")
|
|
684
|
+
if name in ("block", "confirm-manual"):
|
|
685
|
+
action.add_argument("--reason", default="")
|
|
686
|
+
if name == "confirm-manual":
|
|
687
|
+
for flag in ("--refs", "--scenarios", "--confirmed-by", "--source", "--evidence"):
|
|
688
|
+
action.add_argument(flag, default="")
|
|
689
|
+
return parser
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _verify_from_arguments(args: argparse.Namespace, root: str, directory: str) -> dict[str, object]:
|
|
693
|
+
layers = _verify_layers_argument(args.layers, args.checks)
|
|
694
|
+
verify = verify_e2e if args.action == "verify-e2e" else verify_layers
|
|
695
|
+
tasks = [item.strip() for item in getattr(args, "tasks", "").split(",") if item.strip()]
|
|
696
|
+
if tasks and (len(set(tasks)) != len(tasks) or any(not re.fullmatch(r"TASK-\d+", task) for task in tasks)):
|
|
697
|
+
raise ValueError("--tasks must contain distinct TASK IDs")
|
|
698
|
+
scope = {"only_tasks": tasks} if tasks else {}
|
|
699
|
+
return verify(root, directory, layers, args.profile or None, **scope, resume=args.resume,
|
|
700
|
+
deadline=(time.monotonic() + args.deadline) if args.deadline else None,
|
|
701
|
+
max_failures=args.max_failures, fail_fast=args.fail_fast, jobs=args.jobs)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _dispatch_action(args: argparse.Namespace, root: str, directory: str) -> dict[str, object]:
|
|
705
|
+
if args.action == "archive":
|
|
706
|
+
return archive_requirement(root, directory)
|
|
707
|
+
if args.action in ("verify", "verify-e2e"):
|
|
708
|
+
return _verify_from_arguments(args, root, directory)
|
|
709
|
+
if args.action == "cleanup-session":
|
|
710
|
+
return cleanup_session_projections(root, directory)
|
|
711
|
+
if args.action == "confirm-manual":
|
|
712
|
+
refs = [item.strip() for item in args.refs.split(",") if item.strip()]
|
|
713
|
+
scenarios = [item.strip() for item in args.scenarios.split(",") if item.strip()]
|
|
714
|
+
return confirm_manual(root, directory, refs, scenarios, args.confirmed_by, args.source,
|
|
715
|
+
args.reason, args.evidence)
|
|
716
|
+
if args.action == "finish" and (args.batch or args.capability):
|
|
717
|
+
return finish_batch(root, directory, [item for item in args.batch.split(",") if item.strip()],
|
|
718
|
+
args.capability)
|
|
719
|
+
task = locate_task_file(directory, args.task)
|
|
720
|
+
if task is None:
|
|
721
|
+
raise ValueError("task file missing or ambiguous")
|
|
722
|
+
if args.action == "finish":
|
|
723
|
+
return finish_task(root, directory, str(task), args.task)
|
|
724
|
+
if args.action == "block":
|
|
725
|
+
return block_task(root, directory, str(task), args.task, args.reason)
|
|
726
|
+
return resume_task(root, directory, str(task), args.task)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
730
|
+
args = _workflow_parser().parse_args(argv)
|
|
731
|
+
try:
|
|
732
|
+
root = str(Path(args.root).resolve())
|
|
733
|
+
directory = str((Path(root) / args.task_dir).resolve())
|
|
734
|
+
if Path(root) not in Path(directory).parents:
|
|
735
|
+
raise ValueError("task directory outside project")
|
|
736
|
+
recover_transition(root)
|
|
737
|
+
result = _dispatch_action(args, root, directory)
|
|
738
|
+
stdout.write(json.dumps(redact_sensitive_payload(result), ensure_ascii=False))
|
|
739
|
+
return 3 if result.get("decision") in ("block", "provisional") else 0
|
|
740
|
+
except (OSError, ValueError) as exc:
|
|
741
|
+
stdout.write(json.dumps(redact_sensitive_payload({"decision": "block", "error": str(exc)}), ensure_ascii=False))
|
|
742
|
+
return 2
|