@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,755 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unified workflow state machine: single entry for task state transitions.
|
|
3
|
+
|
|
4
|
+
Markdown task files are a readable view; the active marker plus Context are
|
|
5
|
+
the facts. Every transition below validates preconditions, mutates the marker
|
|
6
|
+
through cf_spec_context primitives, and syncs the Markdown view in the same
|
|
7
|
+
call with a recovery journal. Interrupted transitions can be rolled forward;
|
|
8
|
+
concurrent user edits block recovery instead of being overwritten.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
from datetime import date
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Mapping, Optional, Sequence
|
|
20
|
+
from codeflow.spec.cf_active_task import ActiveTask, active_data, load_active_task
|
|
21
|
+
from codeflow.spec.cf_spec_context_model import ContextError, SpecContext, load_context
|
|
22
|
+
from codeflow.task.cf_task_doc import TaskDocument, TaskDocumentError, parse_task_document
|
|
23
|
+
from codeflow.spec.cf_workflow_transaction import recover_transition
|
|
24
|
+
from codeflow.core.cf_task_state import FINISHED_STATUSES
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkflowError(ValueError):
|
|
28
|
+
"""Fail-closed transition error with a stable code."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, code: str, message: str, details: tuple[str, ...] = ()) -> None:
|
|
31
|
+
self.code = code
|
|
32
|
+
self.details = details
|
|
33
|
+
super().__init__(f"[{code}] {message}" + (f": {'; '.join(details)}" if details else ""))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def locate_task_file(task_dir: str, task_id: str) -> Optional[Path]:
|
|
37
|
+
"""Find the unique task file containing the TASK section."""
|
|
38
|
+
matches: list[Path] = []
|
|
39
|
+
for path in sorted(Path(task_dir).glob("*.md")):
|
|
40
|
+
if path.name.endswith((".prd.md", ".design.md", ".scenarios.md")):
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
document = _task_document(str(path))
|
|
44
|
+
except WorkflowError as exc:
|
|
45
|
+
if exc.code == "task_document_empty":
|
|
46
|
+
continue
|
|
47
|
+
raise
|
|
48
|
+
if any(task.id == task_id for task in document.tasks):
|
|
49
|
+
matches.append(path)
|
|
50
|
+
return matches[0] if len(matches) == 1 else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _task_document(task_file: str) -> TaskDocument:
|
|
54
|
+
try:
|
|
55
|
+
text = Path(task_file).read_text(encoding="utf-8")
|
|
56
|
+
except (OSError, UnicodeError) as exc:
|
|
57
|
+
raise WorkflowError("task_read_error", str(exc)) from exc
|
|
58
|
+
try:
|
|
59
|
+
return parse_task_document(text, source=str(task_file))
|
|
60
|
+
except TaskDocumentError as exc:
|
|
61
|
+
raise WorkflowError(exc.code, str(exc), exc.details) from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_task_section(task_file: str, task_id: str) -> str:
|
|
65
|
+
for task in _task_document(task_file).tasks:
|
|
66
|
+
if task.id == task_id:
|
|
67
|
+
return task.body
|
|
68
|
+
raise WorkflowError("task_not_found", task_id, (task_file,))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def section_status(section: str) -> str:
|
|
72
|
+
"""Markdown status; absent Status line means draft (pre-contract tasks)."""
|
|
73
|
+
return parse_task_document(section).tasks[0].status
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def task_truth_issues(task_file: str, task_id: str, context: SpecContext,
|
|
77
|
+
spec_ids: Optional[set[str]] = None) -> tuple[str, ...]:
|
|
78
|
+
"""Return required Rule refs whose status contradicts the Markdown claim.
|
|
79
|
+
|
|
80
|
+
The Markdown is a view. A TASK claiming done/verified while its required
|
|
81
|
+
code (or, for verified, review) Rules are not verified is a documentation
|
|
82
|
+
lie; archive/verify callers must fail closed instead of trusting the prose.
|
|
83
|
+
"""
|
|
84
|
+
section = read_task_section(task_file, task_id)
|
|
85
|
+
status = section_status(section)
|
|
86
|
+
if status not in FINISHED_STATUSES:
|
|
87
|
+
return ()
|
|
88
|
+
stages = ("code", "review") if status == "verified" else ("code",)
|
|
89
|
+
issues: list[str] = []
|
|
90
|
+
for binding in context.bindings:
|
|
91
|
+
for rule in binding.rules:
|
|
92
|
+
if rule.enforcement != "required" or rule.verifier_stage not in stages:
|
|
93
|
+
continue
|
|
94
|
+
stage_status = rule.stage_status.get(rule.verifier_stage)
|
|
95
|
+
actual = stage_status.status if stage_status is not None else "missing"
|
|
96
|
+
if status == "done" and spec_ids is not None and actual == "unverified":
|
|
97
|
+
from codeflow.spec.cf_stage_gate import evidence_deferral_kind
|
|
98
|
+
latest = stage_status.evidence[-1] if stage_status.evidence else None
|
|
99
|
+
deferred = evidence_deferral_kind(latest)
|
|
100
|
+
if deferred == "heavy" or (deferred == "review_stage" and binding.spec_id not in spec_ids):
|
|
101
|
+
continue
|
|
102
|
+
if actual != "verified":
|
|
103
|
+
issues.append(f"{binding.spec_id}#{rule.ref}: {actual}")
|
|
104
|
+
return tuple(sorted(set(issues)))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def section_depends(section: str) -> tuple[str, ...]:
|
|
108
|
+
return parse_task_document(section).tasks[0].depends
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def section_notes(section: str) -> tuple[str, ...]:
|
|
112
|
+
return tuple(
|
|
113
|
+
line.strip()
|
|
114
|
+
for line in section.splitlines()
|
|
115
|
+
if "#NOTES" in line
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _all_statuses(task_file: str) -> Mapping[str, str]:
|
|
120
|
+
return {task.id: task.status for task in _task_document(task_file).tasks}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_startable(task_file: str, task_id: str) -> list[str]:
|
|
124
|
+
"""Collectors of start blockers; empty means the TASK may start."""
|
|
125
|
+
section = read_task_section(task_file, task_id)
|
|
126
|
+
status = section_status(section)
|
|
127
|
+
blockers: list[str] = []
|
|
128
|
+
if status == "blocked":
|
|
129
|
+
blockers.append(f"{task_id} is blocked; resolve Log BLOCKED entries first")
|
|
130
|
+
elif status == "done":
|
|
131
|
+
blockers.append(f"{task_id} is already done; verify contract or reopen explicitly")
|
|
132
|
+
elif status not in ("draft", "in-progress", "provisional"):
|
|
133
|
+
blockers.append(f"{task_id} has unexpected status '{status}'")
|
|
134
|
+
notes = section_notes(section)
|
|
135
|
+
if notes:
|
|
136
|
+
blockers.append(f"{len(notes)} unresolved #NOTES: {notes[0][:80]}")
|
|
137
|
+
statuses = _all_statuses(task_file)
|
|
138
|
+
unmet = [dep for dep in section_depends(section) if statuses.get(dep) not in FINISHED_STATUSES]
|
|
139
|
+
if unmet:
|
|
140
|
+
rendered = ", ".join(dep + "(" + str(statuses.get(dep, "missing")) + ")" for dep in unmet)
|
|
141
|
+
blockers.append("unmet depends: " + rendered)
|
|
142
|
+
return blockers
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _today() -> str:
|
|
146
|
+
return date.today().isoformat()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _log_list_end(section: str, heading: re.Match) -> int:
|
|
150
|
+
"""`### Log` 列表最后一项的末尾偏移;无条目时为标题行末。"""
|
|
151
|
+
body_start = heading.end()
|
|
152
|
+
boundary = re.search(r"(?m)^#{2,3} |^---[ \t]*$", section[body_start:])
|
|
153
|
+
body_end = body_start + (boundary.start() if boundary is not None else len(section) - body_start)
|
|
154
|
+
entries = list(re.finditer(r"(?m)^- .*$", section[body_start:body_end]))
|
|
155
|
+
return body_start + (entries[-1].end() if entries else 0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _trailing_separator_at(section: str) -> int:
|
|
159
|
+
"""段落尾部 `---` 分隔符的起始偏移(取最后一个);无分隔符时为段落末尾。"""
|
|
160
|
+
at = len(section)
|
|
161
|
+
for match in re.finditer(r"(?m)^---[ \t]*$", section):
|
|
162
|
+
at = match.start()
|
|
163
|
+
return at
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _append_log_entry(section: str, log_line: str) -> str:
|
|
167
|
+
"""把生命周期记录追加进 `### Log` 列表(无该小节时在尾部分隔符前新建)。
|
|
168
|
+
|
|
169
|
+
历史缺陷:直接追加到 section 末尾会落在 `---` 分隔符之后,并吞掉下一节前的
|
|
170
|
+
空行;这里始终在段落内容区内插入,保持分隔符与空行原样。
|
|
171
|
+
"""
|
|
172
|
+
heading = re.search(r"(?m)^### Log[ \t]*$", section)
|
|
173
|
+
if heading is not None:
|
|
174
|
+
at = _log_list_end(section, heading)
|
|
175
|
+
return section[:at] + f"\n{log_line}" + section[at:]
|
|
176
|
+
at = _trailing_separator_at(section)
|
|
177
|
+
head, tail = section[:at].rstrip(), section[at:].lstrip("\n")
|
|
178
|
+
body = f"{head}\n\n### Log\n{log_line}\n"
|
|
179
|
+
return body + (f"\n{tail}" if tail else "")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _set_status(section: str, status: str, log_line: str = "") -> str:
|
|
183
|
+
if re.search(r"(?m)^- \*\*Status\*\*:", section):
|
|
184
|
+
section = re.sub(r"(?m)^(- \*\*Status\*\*:)[^\r\n]*", rf"\1 {status}", section, count=1)
|
|
185
|
+
else:
|
|
186
|
+
line_end = section.find("\n")
|
|
187
|
+
if line_end == -1:
|
|
188
|
+
insert_at = len(section)
|
|
189
|
+
elif line_end > 0 and section[line_end - 1] == "\r":
|
|
190
|
+
insert_at = line_end - 1
|
|
191
|
+
else:
|
|
192
|
+
insert_at = line_end
|
|
193
|
+
eol = "\r\n" if "\r\n" in section else "\n"
|
|
194
|
+
section = section[:insert_at] + eol + f"- **Status**: {status}" + section[insert_at:]
|
|
195
|
+
if log_line and log_line not in section.splitlines():
|
|
196
|
+
section = _append_log_entry(section, log_line)
|
|
197
|
+
return section
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _section_span(text: str, task_id: str) -> tuple[int, int]:
|
|
201
|
+
"""Offsets of one TASK section in the original text (line endings preserved)."""
|
|
202
|
+
heading = f"## {task_id}:"
|
|
203
|
+
start = end = -1
|
|
204
|
+
offset = 0
|
|
205
|
+
for line in text.splitlines(keepends=True):
|
|
206
|
+
content = line.rstrip("\r\n")
|
|
207
|
+
if content.startswith(heading):
|
|
208
|
+
if start != -1:
|
|
209
|
+
raise WorkflowError("duplicate_task_id", task_id)
|
|
210
|
+
start = offset
|
|
211
|
+
elif start != -1 and content.startswith("## "):
|
|
212
|
+
end = offset
|
|
213
|
+
break
|
|
214
|
+
offset += len(line)
|
|
215
|
+
if start == -1:
|
|
216
|
+
raise WorkflowError("task_not_found", task_id)
|
|
217
|
+
return start, len(text) if end == -1 else end
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _render_markdown(text: str, task_id: str, status: str, log_line: str = "", prepend: str = "") -> str:
|
|
221
|
+
try:
|
|
222
|
+
document = parse_task_document(text)
|
|
223
|
+
except TaskDocumentError as exc:
|
|
224
|
+
raise WorkflowError(exc.code, str(exc), exc.details) from exc
|
|
225
|
+
if not any(task.id == task_id for task in document.tasks):
|
|
226
|
+
raise WorkflowError("task_not_found", task_id)
|
|
227
|
+
start, end = _section_span(text, task_id)
|
|
228
|
+
section = text[start:end]
|
|
229
|
+
updated = _set_status(section, status, log_line)
|
|
230
|
+
if not prepend and status in ("done", "verified"):
|
|
231
|
+
updated = re.sub(r"(?m)^> (?:PROVISIONAL|BLOCKED):[^\n]*\n?", "", updated)
|
|
232
|
+
if prepend and prepend not in updated:
|
|
233
|
+
if "### Log" in updated:
|
|
234
|
+
updated = updated.replace("### Log", prepend + "\n### Log", 1)
|
|
235
|
+
else:
|
|
236
|
+
at = _trailing_separator_at(updated)
|
|
237
|
+
head, tail = updated[:at].rstrip(), updated[at:].lstrip("\n")
|
|
238
|
+
updated = f"{head}\n\n{prepend}\n" + (f"\n{tail}" if tail else "")
|
|
239
|
+
text = text[:start] + updated + text[end:]
|
|
240
|
+
return re.sub(r"(?m)^(- \*\*Updated\*\*:)[^\r\n]*", rf"\1 {_today()}", text, count=1)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _sync_markdown(task_file: str, task_id: str, status: str, log_line: str = "", prepend: str = "") -> str:
|
|
244
|
+
from codeflow.core.cf_fs import atomic_write_text
|
|
245
|
+
target = Path(task_file)
|
|
246
|
+
text = target.read_text(encoding="utf-8")
|
|
247
|
+
atomic_write_text(target, _render_markdown(text, task_id, status, log_line, prepend))
|
|
248
|
+
return "synced"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _requirement_task_mismatches(directory: str, tasks: Sequence[tuple[Path, str, str]],
|
|
252
|
+
context: Optional[SpecContext]) -> list[dict[str, object]]:
|
|
253
|
+
mismatches: list[dict[str, object]] = []
|
|
254
|
+
supplied = {task_id for _, task_id, _ in tasks}
|
|
255
|
+
if not supplied or len(supplied) != len(tasks):
|
|
256
|
+
return [{"task": "", "issues": ["TASK list is empty or contains duplicates"]}]
|
|
257
|
+
actual: set[str] = set()
|
|
258
|
+
for path in sorted(Path(directory).glob("*.md")):
|
|
259
|
+
if path.name.endswith((".design.md", ".prd.md", ".scenarios.md")):
|
|
260
|
+
continue
|
|
261
|
+
try:
|
|
262
|
+
actual.update(task.id for task in _task_document(str(path)).tasks)
|
|
263
|
+
except WorkflowError as exc:
|
|
264
|
+
if exc.code != "task_document_empty":
|
|
265
|
+
raise
|
|
266
|
+
for task_id in sorted(actual - supplied):
|
|
267
|
+
mismatches.append({"task": task_id, "issues": ["TASK was omitted from requirement verification"]})
|
|
268
|
+
for task_file, task_id, _ in tasks:
|
|
269
|
+
located = locate_task_file(directory, task_id)
|
|
270
|
+
issues: list[str] = []
|
|
271
|
+
if located is None or located.resolve() != task_file.resolve():
|
|
272
|
+
issues.append("TASK file does not belong to this requirement")
|
|
273
|
+
elif section_status(read_task_section(str(task_file), task_id)) not in FINISHED_STATUSES:
|
|
274
|
+
issues.append("TASK is not done or verified")
|
|
275
|
+
elif context is not None:
|
|
276
|
+
issues.extend(task_truth_issues(str(task_file), task_id, context))
|
|
277
|
+
if issues:
|
|
278
|
+
mismatches.append({"task": task_id, "issues": issues})
|
|
279
|
+
return mismatches
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _compact_verified_contexts(root: str, directory: str) -> None:
|
|
283
|
+
from codeflow.evidence.cf_evidence_store import compact_context_file, requirement_key
|
|
284
|
+
for path in sorted(Path(directory).rglob("spec-context.yml")):
|
|
285
|
+
try:
|
|
286
|
+
compact_context_file(path, root=root, requirement=requirement_key(directory))
|
|
287
|
+
except (OSError, ValueError) as exc:
|
|
288
|
+
sys.stderr.write(f"cf_task_workflow: context compaction failed: {exc}\n")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _requirement_layer_issues(result: Mapping[str, object]) -> list[str]:
|
|
292
|
+
required = {"validators", "review", "e2e"}
|
|
293
|
+
selected = result.get("selected_layers")
|
|
294
|
+
layers = result.get("layers")
|
|
295
|
+
if result.get("decision") != "pass" or result.get("profile") != "full":
|
|
296
|
+
return ["requirement verification must pass under the full profile"]
|
|
297
|
+
if not isinstance(selected, list) or set(selected) != required or not isinstance(layers, Mapping):
|
|
298
|
+
return ["validators, review and acceptance results are required"]
|
|
299
|
+
return [f"{name} verification did not pass" for name in sorted(required)
|
|
300
|
+
if not isinstance(layers.get(name), Mapping) or layers[name].get("decision") != "pass"]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _missing_acceptance_refs(tasks: Sequence[tuple[Path, str, str]],
|
|
304
|
+
rows: Sequence[Mapping[str, object]]) -> list[str]:
|
|
305
|
+
scenarios = {row["id"]: row for row in rows}
|
|
306
|
+
issues: list[str] = []
|
|
307
|
+
for task_file, task_id, _ in tasks:
|
|
308
|
+
section = parse_task_document(read_task_section(str(task_file), task_id)).tasks[0]
|
|
309
|
+
refs = set(re.findall(r"\b(?:S|E|B)-\d+\b", section.fields.get("Acceptance-Refs", "")))
|
|
310
|
+
for ref in sorted(refs):
|
|
311
|
+
row = scenarios.get(ref)
|
|
312
|
+
if row is None or row.get("owner") not in (None, "", task_id):
|
|
313
|
+
issues.append(f"{task_id}: acceptance scenario {ref} is missing or belongs to another TASK")
|
|
314
|
+
return issues
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _stale_acceptance_issues(root: str, checks: Mapping[str, Mapping[str, object]],
|
|
318
|
+
rows: Sequence[Mapping[str, object]]) -> list[str]:
|
|
319
|
+
from codeflow.acceptance.cf_acceptance_evidence import stale_scenario_checks
|
|
320
|
+
from codeflow.acceptance.cf_check_inputs import check_input_hashes
|
|
321
|
+
from codeflow.core.cf_exec_base import worktree_fingerprint
|
|
322
|
+
|
|
323
|
+
fingerprint = worktree_fingerprint(root)
|
|
324
|
+
hashes = check_input_hashes(root, checks, fingerprint)
|
|
325
|
+
return [f"{row['id']}: acceptance evidence is stale" for row in rows
|
|
326
|
+
if row.get("status") == "verified" and stale_scenario_checks(row, hashes, fingerprint)]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _requirement_acceptance_issues(root: str, directory: str, tasks: Sequence[tuple[Path, str, str]]) -> list[str]:
|
|
330
|
+
from codeflow.acceptance.cf_acceptance_manifest_core import validate_execution_baseline
|
|
331
|
+
from codeflow.acceptance.cf_acceptance_risk import accepted_risk
|
|
332
|
+
from codeflow.acceptance.cf_acceptance_schema import load_manifest, scenario_check_ids, verified_evidence
|
|
333
|
+
|
|
334
|
+
manifest = Path(directory) / ".acceptance-manifest.json"
|
|
335
|
+
if not manifest.is_file():
|
|
336
|
+
required = [task_id for task_file, task_id, _ in tasks
|
|
337
|
+
if parse_task_document(read_task_section(str(task_file), task_id)).tasks[0]
|
|
338
|
+
.fields.get("Acceptance-Refs", "").strip().upper() not in ("", "N/A", "NONE", "-")]
|
|
339
|
+
return [f"{task_id}: acceptance manifest missing" for task_id in required]
|
|
340
|
+
data = load_manifest(manifest)
|
|
341
|
+
validate_execution_baseline(manifest, data)
|
|
342
|
+
task_file = data.get("task_file")
|
|
343
|
+
if not isinstance(task_file, str) or not task_file.strip() or {
|
|
344
|
+
file.resolve() for file, _, _ in tasks
|
|
345
|
+
} != {(manifest.parent / task_file).resolve()}:
|
|
346
|
+
return ["acceptance manifest is not bound to these TASKs"]
|
|
347
|
+
owners = {task_id for _, task_id, _ in tasks}
|
|
348
|
+
checks = {str(check["id"]): check for check in data.get("checks", [])}
|
|
349
|
+
issues = _missing_acceptance_refs(tasks, data["scenarios"])
|
|
350
|
+
rows = [row for row in data["scenarios"] if (not row.get("owner") or row.get("owner") in owners)
|
|
351
|
+
and row.get("exec") != "aspirational"]
|
|
352
|
+
issues.extend(_stale_acceptance_issues(root, checks, rows))
|
|
353
|
+
for row in rows:
|
|
354
|
+
if verified_evidence(row):
|
|
355
|
+
continue
|
|
356
|
+
if row.get("status") == "accepted_risk" and any(
|
|
357
|
+
accepted_risk(checks.get(check_id), row) is not None for check_id in scenario_check_ids(row)
|
|
358
|
+
):
|
|
359
|
+
continue
|
|
360
|
+
issues.append(f"{row['id']}: acceptance evidence is not verified")
|
|
361
|
+
return issues
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def complete_requirement_verification(root: str, directory: str, tasks: Sequence[tuple[Path, str, str]],
|
|
365
|
+
result: dict[str, object]) -> dict[str, object]:
|
|
366
|
+
"""Promote finished TASKs only after full layers and persisted evidence pass."""
|
|
367
|
+
layer_issues = _requirement_layer_issues(result)
|
|
368
|
+
if layer_issues or (Path(root) / ".code-flow/.active-task.json").exists():
|
|
369
|
+
return {**result, "decision": "block", "reason": "requirement_verification_incomplete",
|
|
370
|
+
"issues": layer_issues or ["active TASK still exists"]}
|
|
371
|
+
context_path = Path(directory) / "spec-context.yml"
|
|
372
|
+
context = load_context(str(context_path)) if context_path.is_file() else None
|
|
373
|
+
mismatches = _requirement_task_mismatches(directory, tasks, context)
|
|
374
|
+
if mismatches:
|
|
375
|
+
return {**result, "decision": "block", "reason": "task_status_truth_mismatch", "tasks": mismatches}
|
|
376
|
+
if context is not None:
|
|
377
|
+
from codeflow.spec.cf_stage_gate import validate_stage
|
|
378
|
+
issues = [item.message for stage in ("code", "review") for item in validate_stage(context, stage).errors]
|
|
379
|
+
if issues:
|
|
380
|
+
return {**result, "decision": "block", "reason": "requirement_spec_unverified", "issues": issues}
|
|
381
|
+
try:
|
|
382
|
+
issues = _requirement_acceptance_issues(root, directory, tasks)
|
|
383
|
+
except (OSError, ValueError) as exc:
|
|
384
|
+
issues = [str(exc)]
|
|
385
|
+
if issues:
|
|
386
|
+
return {**result, "decision": "block", "reason": "requirement_acceptance_unverified", "issues": issues}
|
|
387
|
+
_compact_verified_contexts(root, directory)
|
|
388
|
+
for task_file, task_id, _ in tasks:
|
|
389
|
+
_sync_markdown(str(task_file), task_id, "verified")
|
|
390
|
+
return result
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _commit_state(root: str, task_file: str, task_id: str, status: str, previous: Optional[ActiveTask],
|
|
394
|
+
updated: Optional[ActiveTask], log_line: str = "", prepend: str = "", *,
|
|
395
|
+
context_change: Optional[tuple[Path, str, str]] = None) -> str:
|
|
396
|
+
from codeflow.spec.cf_workflow_transaction import commit_transition
|
|
397
|
+
marker = Path(root) / ".code-flow/.active-task.json"
|
|
398
|
+
marker_before = marker.read_bytes().decode("utf-8") if marker.exists() else None
|
|
399
|
+
actual = json.loads(marker_before) if marker_before is not None else None
|
|
400
|
+
expected = active_data(previous) if previous is not None else None
|
|
401
|
+
if actual != expected:
|
|
402
|
+
raise WorkflowError("active_mismatch", "active task changed before commit")
|
|
403
|
+
marker_after = json.dumps(active_data(updated), ensure_ascii=False, indent=2) + "\n" if updated is not None else None
|
|
404
|
+
task = Path(task_file)
|
|
405
|
+
before = task.read_bytes().decode("utf-8")
|
|
406
|
+
after = _render_markdown(before, task_id, status, log_line, prepend)
|
|
407
|
+
try:
|
|
408
|
+
commit_transition(root, task, before, after, marker_before, marker_after, context_change=context_change)
|
|
409
|
+
except (OSError, ValueError) as exc:
|
|
410
|
+
raise WorkflowError("transition_failed", str(exc)) from exc
|
|
411
|
+
return "synced"
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _resolve_paths(root: str, task_dir: str, task_file: str) -> tuple[Path, Path]:
|
|
415
|
+
base = Path(root)
|
|
416
|
+
directory = Path(task_dir) if Path(task_dir).is_absolute() else base / task_dir
|
|
417
|
+
candidate = Path(task_file) if Path(task_file).is_absolute() else base / task_file
|
|
418
|
+
if base.resolve() not in candidate.resolve().parents or candidate.parent.resolve() != directory.resolve() or not candidate.is_file():
|
|
419
|
+
raise WorkflowError("invalid_task_file", str(candidate), (str(directory),))
|
|
420
|
+
return directory, candidate
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _validate_start_manifest(directory: Path, candidate: Path) -> None:
|
|
424
|
+
if "## Acceptance Coverage" not in candidate.read_text(encoding="utf-8"):
|
|
425
|
+
return
|
|
426
|
+
manifest_file = directory / ".acceptance-manifest.json"
|
|
427
|
+
if not manifest_file.is_file():
|
|
428
|
+
raise WorkflowError("acceptance_manifest_missing", str(manifest_file), ("run plan verification and lock the manifest",))
|
|
429
|
+
from codeflow.acceptance.cf_acceptance_manifest_core import validate_manifest
|
|
430
|
+
valid, reason = validate_manifest(str(candidate), str(manifest_file))
|
|
431
|
+
if not valid:
|
|
432
|
+
raise WorkflowError(reason or "acceptance_manifest_invalid", str(manifest_file), ("run plan verification before coding",))
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _write_projection(root: str, candidate: Path, context: SpecContext, task_id: str, session_output: str) -> Path:
|
|
436
|
+
from codeflow.core.cf_fs import atomic_write_text
|
|
437
|
+
from codeflow.spec.cf_task_projection import project_task_session
|
|
438
|
+
try:
|
|
439
|
+
projection = project_task_session(context, str(candidate), task_id)
|
|
440
|
+
except ValueError as exc:
|
|
441
|
+
raise WorkflowError("task_projection_failed", str(exc)) from exc
|
|
442
|
+
if projection.truncated:
|
|
443
|
+
raise WorkflowError("task_projection_truncated", task_id, ("split the TASK before coding",))
|
|
444
|
+
output = Path(session_output) if session_output else (
|
|
445
|
+
Path(root) / ".code-flow/specs/_session" / f"task-{candidate.stem}.md"
|
|
446
|
+
)
|
|
447
|
+
try:
|
|
448
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
449
|
+
atomic_write_text(output, projection.text)
|
|
450
|
+
except (OSError, ValueError) as exc:
|
|
451
|
+
raise WorkflowError("session_write_failed", str(exc)) from exc
|
|
452
|
+
return output
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def remove_session_projection(root: str, task_file: str) -> str:
|
|
456
|
+
"""删除任务在 `specs/_session/` 的投影,返回 removed / absent / error: ...。
|
|
457
|
+
|
|
458
|
+
投影是 start 生成的瞬时文件(`.gitignore` 忽略、不参与 Spec Catalog 与审计),
|
|
459
|
+
任务完成后必须清理,避免往期任务的 Required Rules/Contract 被后续会话读到。
|
|
460
|
+
清理失败不影响 Done 裁决,但状态必须显式返回。
|
|
461
|
+
"""
|
|
462
|
+
projection = Path(root) / ".code-flow/specs/_session" / f"task-{Path(task_file).stem}.md"
|
|
463
|
+
try:
|
|
464
|
+
projection.unlink()
|
|
465
|
+
except FileNotFoundError:
|
|
466
|
+
return "absent"
|
|
467
|
+
except OSError as exc:
|
|
468
|
+
return f"error: {exc}"
|
|
469
|
+
return "removed"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def cleanup_session_projections(root: str, directory: str) -> dict[str, object]:
|
|
473
|
+
"""按需求目录逐个任务文件删除 `_session` 投影(只动本需求的 task-<stem>.md)。"""
|
|
474
|
+
removed: list[str] = []
|
|
475
|
+
absent: list[str] = []
|
|
476
|
+
errors: list[dict[str, str]] = []
|
|
477
|
+
for task_file in sorted(Path(directory).glob("*.md")):
|
|
478
|
+
if task_file.name.endswith((".design.md", ".prd.md")):
|
|
479
|
+
continue
|
|
480
|
+
relative = f".code-flow/specs/_session/task-{task_file.stem}.md"
|
|
481
|
+
status = remove_session_projection(root, str(task_file))
|
|
482
|
+
if status == "removed":
|
|
483
|
+
removed.append(relative)
|
|
484
|
+
elif status == "absent":
|
|
485
|
+
absent.append(relative)
|
|
486
|
+
else:
|
|
487
|
+
errors.append({"path": relative, "error": status})
|
|
488
|
+
return {
|
|
489
|
+
"ok": not errors,
|
|
490
|
+
"decision": "block" if errors else "pass",
|
|
491
|
+
"removed": removed,
|
|
492
|
+
"absent": absent,
|
|
493
|
+
"errors": errors,
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _worktree_registration_issue(root: str, task_id: str) -> str:
|
|
498
|
+
"""Refuse a parallel worktree that is not registered in its run.json.
|
|
499
|
+
|
|
500
|
+
A background worker must not self-provision a worktree: the run ledger is
|
|
501
|
+
what collect/merge later reads. Plain single-task runs are unaffected
|
|
502
|
+
because their root is not under `.code-flow/worktrees/`.
|
|
503
|
+
"""
|
|
504
|
+
path = Path(root).resolve()
|
|
505
|
+
parts = path.parts
|
|
506
|
+
try:
|
|
507
|
+
flow_index = parts.index(".code-flow")
|
|
508
|
+
except ValueError:
|
|
509
|
+
return ""
|
|
510
|
+
if flow_index + 1 >= len(parts) or parts[flow_index + 1] != "worktrees":
|
|
511
|
+
return ""
|
|
512
|
+
run_dir = path.parent
|
|
513
|
+
meta_path = run_dir / "run.json"
|
|
514
|
+
if not meta_path.is_file():
|
|
515
|
+
return f"{path} is not registered in a parallel run (missing {meta_path})"
|
|
516
|
+
try:
|
|
517
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
518
|
+
except (OSError, ValueError) as exc:
|
|
519
|
+
return f"run.json unreadable: {exc}"
|
|
520
|
+
if not isinstance(meta, Mapping):
|
|
521
|
+
return f"run.json must be an object: {meta_path}"
|
|
522
|
+
if (meta.get("version") != 1
|
|
523
|
+
or not isinstance(meta.get("task_file"), str) or not meta.get("task_file")
|
|
524
|
+
or not isinstance(meta.get("base_head"), str) or not meta.get("base_head")):
|
|
525
|
+
return f"run.json is incomplete: {meta_path}"
|
|
526
|
+
entries = meta.get("worktrees")
|
|
527
|
+
if not isinstance(entries, list):
|
|
528
|
+
return f"run.json worktrees must be a list: {meta_path}"
|
|
529
|
+
for entry in entries:
|
|
530
|
+
if not isinstance(entry, Mapping) or entry.get("task") != task_id:
|
|
531
|
+
continue
|
|
532
|
+
raw = entry.get("path")
|
|
533
|
+
if not isinstance(raw, str) or not Path(raw).is_absolute():
|
|
534
|
+
return f"run.json path for {task_id} must be an absolute path"
|
|
535
|
+
entry_path = Path(raw).resolve()
|
|
536
|
+
if entry_path == path:
|
|
537
|
+
return ""
|
|
538
|
+
return f"worktree path mismatch for {task_id}: run.json={entry_path}, cwd={path}"
|
|
539
|
+
return f"{task_id} is not registered in {meta_path}"
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _start_paths(root: str, task_dir: str, task_file: str, task_id: str) -> tuple[Path, Path, Path]:
|
|
543
|
+
recover_transition(root)
|
|
544
|
+
directory, candidate = _resolve_paths(root, task_dir, task_file)
|
|
545
|
+
registration = _worktree_registration_issue(root, task_id)
|
|
546
|
+
if registration:
|
|
547
|
+
raise WorkflowError("worktree_not_registered", task_id, (registration,))
|
|
548
|
+
blockers = check_startable(str(candidate), task_id)
|
|
549
|
+
marker_active = (Path(root) / ".code-flow" / ".active-task.json").exists()
|
|
550
|
+
if marker_active:
|
|
551
|
+
blockers = [f"active_exists: run cf-spec doctor before starting {task_id}"] + blockers
|
|
552
|
+
if blockers:
|
|
553
|
+
raise WorkflowError("start_blocked", task_id, tuple(blockers))
|
|
554
|
+
|
|
555
|
+
context_path = directory / "spec-context.yml"
|
|
556
|
+
if not context_path.is_file():
|
|
557
|
+
raise WorkflowError(
|
|
558
|
+
"context_missing",
|
|
559
|
+
str(context_path),
|
|
560
|
+
("run cf-task:plan (bind --stage plan) or cf-spec refresh to create spec-context.yml before start",),
|
|
561
|
+
)
|
|
562
|
+
return directory, candidate, context_path
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _start_e2e_plan(directory: Path, candidate: Path, task_id: str) -> dict[str, object]:
|
|
566
|
+
_validate_start_manifest(directory, candidate)
|
|
567
|
+
from codeflow.acceptance.cf_acceptance_plan import build_e2e_plan
|
|
568
|
+
try:
|
|
569
|
+
e2e_plan = build_e2e_plan(
|
|
570
|
+
str(directory / ".acceptance-manifest.json"), str(candidate), task_id
|
|
571
|
+
)
|
|
572
|
+
except (OSError, ValueError) as exc:
|
|
573
|
+
raise WorkflowError("acceptance_manifest_invalid", task_id, (str(exc),)) from exc
|
|
574
|
+
quality = e2e_plan.get("quality")
|
|
575
|
+
if isinstance(quality, Mapping) and quality.get("ok") is not True:
|
|
576
|
+
errors = quality.get("errors") if isinstance(quality.get("errors"), list) else []
|
|
577
|
+
details = tuple(
|
|
578
|
+
f"{item.get('scenario', '')}:{item.get('message', '')}".strip(":")
|
|
579
|
+
for item in errors[:5] if isinstance(item, Mapping)
|
|
580
|
+
)
|
|
581
|
+
raise WorkflowError("e2e_plan_invalid", task_id, details or ("E2E plan quality gate failed",))
|
|
582
|
+
return e2e_plan
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _start_active(root: str, directory: Path, task_id: str, current_hash: str,
|
|
586
|
+
owned_paths: Sequence[str]) -> ActiveTask:
|
|
587
|
+
from codeflow.spec.cf_active_task import new_active
|
|
588
|
+
if any(not isinstance(item, str) for item in owned_paths):
|
|
589
|
+
raise WorkflowError("invalid_owned_paths", "owned_paths must contain only strings")
|
|
590
|
+
try:
|
|
591
|
+
return replace(new_active(root, str(directory), task_id, current_hash, tuple(owned_paths)), status="active")
|
|
592
|
+
except (OSError, ValueError) as exc:
|
|
593
|
+
code = getattr(exc, "code", "activation_failed")
|
|
594
|
+
raise WorkflowError(str(code), str(exc)) from exc
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def start_task(
|
|
598
|
+
root: str,
|
|
599
|
+
task_dir: str,
|
|
600
|
+
task_file: str,
|
|
601
|
+
task_id: str,
|
|
602
|
+
owned_paths: Sequence[str] = (),
|
|
603
|
+
session_output: str = "",
|
|
604
|
+
) -> dict[str, object]:
|
|
605
|
+
"""Run the Start Gate before persisting the Context, marker and TASK view."""
|
|
606
|
+
from codeflow.spec.cf_context_refresh import refresh_context
|
|
607
|
+
from codeflow.spec.cf_spec_context_model import context_sha256, serialize_context
|
|
608
|
+
|
|
609
|
+
directory, candidate, context_path = _start_paths(root, task_dir, task_file, task_id)
|
|
610
|
+
context_bytes = context_path.read_bytes()
|
|
611
|
+
refreshed = refresh_context(load_context(str(context_path), source=context_bytes), root, artifact_root=str(directory))
|
|
612
|
+
current_hash = context_sha256(refreshed.context)
|
|
613
|
+
e2e_plan = _start_e2e_plan(directory, candidate, task_id)
|
|
614
|
+
active = _start_active(root, directory, task_id, current_hash, owned_paths)
|
|
615
|
+
# Check and write the bounded projection before saving a refreshed Context;
|
|
616
|
+
# invalid refs, an output error, or unowned changes must leave it untouched.
|
|
617
|
+
output = _write_projection(root, candidate, refreshed.context, task_id, session_output)
|
|
618
|
+
try:
|
|
619
|
+
context_change = (context_path, context_bytes.decode("utf-8"), serialize_context(refreshed.context))
|
|
620
|
+
markdown = _commit_state(root, str(candidate), task_id, "in-progress", None, active,
|
|
621
|
+
f"- [{_today()}] started", context_change=context_change)
|
|
622
|
+
except (OSError, ValueError) as exc:
|
|
623
|
+
code = getattr(exc, "code", "activation_failed")
|
|
624
|
+
raise WorkflowError(str(code), str(exc)) from exc
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
"ok": True,
|
|
628
|
+
"context_sha256": current_hash,
|
|
629
|
+
"refresh_changes": [item.__dict__ for item in refreshed.changes],
|
|
630
|
+
"active": active_data(active),
|
|
631
|
+
"session_output": str(output),
|
|
632
|
+
"acceptance_manifest": str(directory / ".acceptance-manifest.json") if (directory / ".acceptance-manifest.json").is_file() else None,
|
|
633
|
+
"e2e_plan": e2e_plan,
|
|
634
|
+
"markdown": markdown,
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def require_active_task(root: str, task_id: str, task_dir: str, task_file: str) -> None:
|
|
639
|
+
recover_transition(root)
|
|
640
|
+
try:
|
|
641
|
+
active = load_active_task(root)
|
|
642
|
+
except (ContextError, OSError, ValueError) as exc:
|
|
643
|
+
raise WorkflowError("no_active_task", str(exc)) from exc
|
|
644
|
+
directory, candidate = _resolve_paths(root, task_dir, task_file)
|
|
645
|
+
if active.task_id != task_id or (Path(root) / active.task_dir).resolve() != directory.resolve():
|
|
646
|
+
raise WorkflowError("active_mismatch", f"marker={active.task_id} requested={task_id}")
|
|
647
|
+
located = locate_task_file(str(directory), task_id)
|
|
648
|
+
if located is None or located.resolve() != candidate.resolve():
|
|
649
|
+
raise WorkflowError("active_mismatch", "TASK file must be unique and match the active demand")
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def block_task(root: str, task_dir: str, task_file: str, task_id: str, reason: str) -> dict[str, object]:
|
|
653
|
+
"""Block a TASK: marker (when it is ours and active) plus Markdown view."""
|
|
654
|
+
if not reason.strip():
|
|
655
|
+
raise WorkflowError("block_reason_required", task_id)
|
|
656
|
+
recover_transition(root)
|
|
657
|
+
_, target = _resolve_paths(root, task_dir, task_file)
|
|
658
|
+
task_file = str(target)
|
|
659
|
+
section = read_task_section(task_file, task_id)
|
|
660
|
+
if section_status(section) in FINISHED_STATUSES:
|
|
661
|
+
raise WorkflowError("task_already_done", task_id, ("completed TASKs cannot be blocked",))
|
|
662
|
+
marker = "skipped_no_marker"
|
|
663
|
+
active = None
|
|
664
|
+
updated = None
|
|
665
|
+
try:
|
|
666
|
+
active = load_active_task(root)
|
|
667
|
+
require_active_task(root, task_id, task_dir, task_file)
|
|
668
|
+
if active.task_id == task_id and active.status == "active":
|
|
669
|
+
updated = replace(active, status="blocked")
|
|
670
|
+
marker = "blocked"
|
|
671
|
+
elif active.task_id == task_id:
|
|
672
|
+
marker = active.status
|
|
673
|
+
updated = active
|
|
674
|
+
except ContextError:
|
|
675
|
+
if (Path(root) / ".code-flow/.active-task.json").exists():
|
|
676
|
+
raise
|
|
677
|
+
marker = "skipped_no_marker"
|
|
678
|
+
today = _today()
|
|
679
|
+
markdown = _commit_state(
|
|
680
|
+
root, task_file, task_id, "blocked", active, updated,
|
|
681
|
+
f"- [{today}] blocked ({reason})",
|
|
682
|
+
prepend=f"> BLOCKED: {reason}",
|
|
683
|
+
)
|
|
684
|
+
return {"ok": True, "task_id": task_id, "marker": marker, "markdown": markdown}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def resume_task(root: str, task_dir: str, task_file: str, task_id: str) -> dict[str, object]:
|
|
688
|
+
"""Resume a blocked/paused TASK after its blockers clear."""
|
|
689
|
+
recover_transition(root)
|
|
690
|
+
_, target = _resolve_paths(root, task_dir, task_file)
|
|
691
|
+
task_file = str(target)
|
|
692
|
+
active = None
|
|
693
|
+
section = read_task_section(task_file, task_id)
|
|
694
|
+
if (Path(root) / ".code-flow/.active-task.json").exists():
|
|
695
|
+
require_active_task(root, task_id, task_dir, task_file)
|
|
696
|
+
active = load_active_task(root)
|
|
697
|
+
allowed_active = active.status in ("blocked", "paused", "provisional")
|
|
698
|
+
provisional_active = active.status == "active" and section_status(section) == "provisional"
|
|
699
|
+
if not allowed_active and not provisional_active:
|
|
700
|
+
raise WorkflowError("resume_unexpected_state", active.status, (task_id,))
|
|
701
|
+
if active is None and section_status(section) not in ("blocked", "provisional"):
|
|
702
|
+
raise WorkflowError("resume_unexpected_state", section_status(section), (task_id,))
|
|
703
|
+
notes = section_notes(section)
|
|
704
|
+
statuses = _all_statuses(task_file)
|
|
705
|
+
unmet = [dep for dep in section_depends(section) if statuses.get(dep) not in FINISHED_STATUSES]
|
|
706
|
+
if notes or unmet:
|
|
707
|
+
raise WorkflowError(
|
|
708
|
+
"resume_blocked", task_id,
|
|
709
|
+
tuple(([f"{len(notes)} unresolved #NOTES"] if notes else []) + ([f"unmet depends: {', '.join(unmet)}"] if unmet else [])),
|
|
710
|
+
)
|
|
711
|
+
resumed = replace(active, status="active") if active is not None else None
|
|
712
|
+
status = "in-progress" if resumed is not None else "draft"
|
|
713
|
+
markdown = _commit_state(root, task_file, task_id, status, active, resumed, f"- [{_today()}] resumed ({status})")
|
|
714
|
+
return {"ok": True, "task_id": task_id, "marker": resumed.status if resumed else "absent", "markdown": markdown}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def complete_task(root: str, task_dir: str, task_file: str, task_id: str, gate_passed: bool) -> dict[str, object]:
|
|
718
|
+
"""Complete a TASK after its Done Gate passed; syncs both records."""
|
|
719
|
+
if not gate_passed:
|
|
720
|
+
raise WorkflowError("gate_failed", task_id, ("Done Gate 未通过",))
|
|
721
|
+
require_active_task(root, task_id, task_dir, task_file)
|
|
722
|
+
_, target = _resolve_paths(root, task_dir, task_file)
|
|
723
|
+
task_file = str(target)
|
|
724
|
+
active = load_active_task(root)
|
|
725
|
+
if active.status != "active":
|
|
726
|
+
raise WorkflowError("invalid_active_transition", active.status)
|
|
727
|
+
completed = replace(active, status="completed")
|
|
728
|
+
|
|
729
|
+
markdown = _commit_state(root, task_file, task_id, "done", active, None, f"- [{_today()}] completed (done)")
|
|
730
|
+
return {"ok": True, "task_id": task_id, "active": active_data(completed), "markdown": markdown}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def provisional_task(root: str, task_dir: str, task_file: str, task_id: str, reason: str) -> dict[str, object]:
|
|
734
|
+
"""Record a provisional finish without claiming done.
|
|
735
|
+
|
|
736
|
+
The active marker stays active so the same TASK can be retried after the
|
|
737
|
+
missing checks are fixed; provisional is explicitly open/unfinished for
|
|
738
|
+
dependency, collect and archive gates.
|
|
739
|
+
"""
|
|
740
|
+
recover_transition(root)
|
|
741
|
+
require_active_task(root, task_id, task_dir, task_file)
|
|
742
|
+
_, target = _resolve_paths(root, task_dir, task_file)
|
|
743
|
+
task_file = str(target)
|
|
744
|
+
if section_status(read_task_section(task_file, task_id)) in FINISHED_STATUSES:
|
|
745
|
+
raise WorkflowError("task_already_done", task_id, ("completed TASKs cannot become provisional",))
|
|
746
|
+
active = load_active_task(root)
|
|
747
|
+
if active.status != "active":
|
|
748
|
+
raise WorkflowError("invalid_active_transition", active.status)
|
|
749
|
+
clean_reason = " ".join(reason.split())
|
|
750
|
+
markdown = _commit_state(
|
|
751
|
+
root, task_file, task_id, "provisional", active, active,
|
|
752
|
+
f"- [{_today()}] provisional ({clean_reason})",
|
|
753
|
+
prepend=f"> PROVISIONAL: {clean_reason}",
|
|
754
|
+
)
|
|
755
|
+
return {"ok": True, "task_id": task_id, "marker": "active", "markdown": markdown}
|