@michaelxwb/code-flow 0.6.4 → 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 -40
- 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 +67 -35
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
- 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 -40
- 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 +67 -35
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
- 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 -40
- 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 +67 -35
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
- 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 -40
- 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 +68 -41
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
- 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 -1714
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
- 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 -380
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
- 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} +37 -3
- 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} +252 -26
- 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 -299
- 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,412 @@
|
|
|
1
|
+
"""Active task marker, lock and Git ownership scope.
|
|
2
|
+
|
|
3
|
+
The marker is the persisted fact behind a running TASK; the Markdown view is
|
|
4
|
+
synced elsewhere. This module owns marker serialization, the cross-process
|
|
5
|
+
lock, and the Git baseline that defines owned paths.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, replace
|
|
11
|
+
from fnmatch import fnmatchcase
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Mapping, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from codeflow.core import cf_git
|
|
18
|
+
from codeflow.spec.cf_spec_context_model import (
|
|
19
|
+
ContextError,
|
|
20
|
+
atomic_text,
|
|
21
|
+
mapping_field,
|
|
22
|
+
now_iso,
|
|
23
|
+
sequence_field,
|
|
24
|
+
string_field,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ACTIVE_STATUSES = frozenset(("activating", "active", "paused", "blocked", "completed"))
|
|
29
|
+
DEFAULT_ACTIVE_EXCLUDES = (
|
|
30
|
+
".code-flow/tasks/*",
|
|
31
|
+
".code-flow/specs/_session/*",
|
|
32
|
+
".code-flow/migrations/*",
|
|
33
|
+
".code-flow/.*state*",
|
|
34
|
+
".code-flow/.*cache*",
|
|
35
|
+
".code-flow/.session-log.jsonl",
|
|
36
|
+
".code-flow/.active-task.json",
|
|
37
|
+
".code-flow/.active-task.lock",
|
|
38
|
+
".code-flow/.stop.lock",
|
|
39
|
+
".code-flow/worktrees/*",
|
|
40
|
+
".code-flow/sessions/*",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class PathSnapshot:
|
|
46
|
+
status: str
|
|
47
|
+
content_sha256: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ActiveBaseline:
|
|
52
|
+
head: Optional[str]
|
|
53
|
+
captured_at: str
|
|
54
|
+
preexisting_changes: Mapping[str, PathSnapshot]
|
|
55
|
+
# head is frozen at start and never rewritten: task scope is the union of
|
|
56
|
+
# everything committed since head plus all working-tree changes.
|
|
57
|
+
# last_seen_head is observation-only (diagnostics), never scope input.
|
|
58
|
+
last_seen_head: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ActiveTask:
|
|
63
|
+
version: int
|
|
64
|
+
task_dir: str
|
|
65
|
+
task_id: str
|
|
66
|
+
status: str
|
|
67
|
+
context_sha256: str
|
|
68
|
+
baseline: ActiveBaseline
|
|
69
|
+
owned_paths: tuple[str, ...]
|
|
70
|
+
excluded_paths: tuple[str, ...]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class DoctorResult:
|
|
75
|
+
action: str
|
|
76
|
+
active: ActiveTask
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def active_paths(root: str) -> tuple[Path, Path]:
|
|
80
|
+
flow = Path(root) / ".code-flow"
|
|
81
|
+
return flow / ".active-task.json", flow / ".active-task.lock"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _context_git_error(exc: cf_git.GitError, root: str) -> ContextError:
|
|
85
|
+
codes = ("git_unavailable", "invalid_git_status")
|
|
86
|
+
code = exc.code if exc.code in codes else "git_error"
|
|
87
|
+
return ContextError(code, "git", exc.message, root)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _git_head(root: str) -> Optional[str]:
|
|
91
|
+
try:
|
|
92
|
+
return cf_git.run_git(root, ("rev-parse", "HEAD")).strip()
|
|
93
|
+
except cf_git.GitError as exc:
|
|
94
|
+
if "unknown revision" in exc.message or "ambiguous argument" in exc.message:
|
|
95
|
+
return None
|
|
96
|
+
raise _context_git_error(exc, root) from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def git_changes(root: str) -> Mapping[str, str]:
|
|
100
|
+
try:
|
|
101
|
+
return cf_git.changed_entries(root)
|
|
102
|
+
except cf_git.GitError as exc:
|
|
103
|
+
raise _context_git_error(exc, root) from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_excluded(path: str, patterns: Sequence[str]) -> bool:
|
|
107
|
+
return any(fnmatchcase(path, pattern) for pattern in patterns)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def active_data(active: ActiveTask) -> dict[str, object]:
|
|
111
|
+
changes = {
|
|
112
|
+
path: {"status": item.status, "content_sha256": item.content_sha256}
|
|
113
|
+
for path, item in sorted(active.baseline.preexisting_changes.items())
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
"version": active.version,
|
|
117
|
+
"task_dir": active.task_dir,
|
|
118
|
+
"task_id": active.task_id,
|
|
119
|
+
"status": active.status,
|
|
120
|
+
"context_sha256": active.context_sha256,
|
|
121
|
+
"baseline": {
|
|
122
|
+
"head": active.baseline.head,
|
|
123
|
+
"last_seen_head": active.baseline.last_seen_head,
|
|
124
|
+
"captured_at": active.baseline.captured_at,
|
|
125
|
+
"preexisting_changes": changes,
|
|
126
|
+
},
|
|
127
|
+
"owned_paths": list(active.owned_paths),
|
|
128
|
+
"excluded_paths": list(active.excluded_paths),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _snapshot_from_data(value: object, path: str) -> PathSnapshot:
|
|
133
|
+
data = mapping_field(value, "preexisting_changes[]", path)
|
|
134
|
+
content_hash = data.get("content_sha256")
|
|
135
|
+
return PathSnapshot(
|
|
136
|
+
string_field(data.get("status"), "status", path),
|
|
137
|
+
content_hash if isinstance(content_hash, str) else "",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def active_from_data(value: object, path: str) -> ActiveTask:
|
|
142
|
+
data = mapping_field(value, "active", path)
|
|
143
|
+
baseline_data = mapping_field(data.get("baseline"), "baseline", path)
|
|
144
|
+
raw_changes = mapping_field(
|
|
145
|
+
baseline_data.get("preexisting_changes"), "baseline.preexisting_changes", path
|
|
146
|
+
)
|
|
147
|
+
version = data.get("version")
|
|
148
|
+
head = baseline_data.get("head")
|
|
149
|
+
if version != 1 or (head is not None and not isinstance(head, str)):
|
|
150
|
+
raise ContextError("invalid_active_marker", "version/baseline.head", "schema 不受支持", path)
|
|
151
|
+
status = string_field(data.get("status"), "status", path)
|
|
152
|
+
if status not in ACTIVE_STATUSES:
|
|
153
|
+
raise ContextError("invalid_active_marker", "status", status, path)
|
|
154
|
+
baseline = ActiveBaseline(
|
|
155
|
+
head,
|
|
156
|
+
string_field(baseline_data.get("captured_at"), "captured_at", path),
|
|
157
|
+
{key: _snapshot_from_data(item, path) for key, item in raw_changes.items()},
|
|
158
|
+
baseline_data.get("last_seen_head") if isinstance(baseline_data.get("last_seen_head"), str) else None,
|
|
159
|
+
)
|
|
160
|
+
return ActiveTask(
|
|
161
|
+
1,
|
|
162
|
+
string_field(data.get("task_dir"), "task_dir", path),
|
|
163
|
+
string_field(data.get("task_id"), "task_id", path),
|
|
164
|
+
status,
|
|
165
|
+
string_field(data.get("context_sha256"), "context_sha256", path),
|
|
166
|
+
baseline,
|
|
167
|
+
tuple(string_field(item, "owned_paths[]", path) for item in sequence_field(data.get("owned_paths"), "owned_paths", path)),
|
|
168
|
+
tuple(string_field(item, "excluded_paths[]", path) for item in sequence_field(data.get("excluded_paths"), "excluded_paths", path)),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def save_active_task(root: str, active: ActiveTask) -> None:
|
|
173
|
+
marker = active_paths(root)[0]
|
|
174
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
atomic_text(marker, json.dumps(active_data(active), ensure_ascii=False, indent=2) + "\n")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load_active_task(root: str) -> ActiveTask:
|
|
179
|
+
marker = active_paths(root)[0]
|
|
180
|
+
try:
|
|
181
|
+
value = json.loads(marker.read_text(encoding="utf-8"))
|
|
182
|
+
return active_from_data(value, str(marker))
|
|
183
|
+
except ContextError:
|
|
184
|
+
raise
|
|
185
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
186
|
+
raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _lock_stale(lock: Path) -> bool:
|
|
190
|
+
"""A lock is stale when its file is unreadable, carries no valid PID, or the
|
|
191
|
+
owning process is gone. A lock we cannot prove dead is never deleted (PID
|
|
192
|
+
reuse is accepted as a rare race)."""
|
|
193
|
+
try:
|
|
194
|
+
data = json.loads(lock.read_text(encoding="utf-8"))
|
|
195
|
+
except (OSError, json.JSONDecodeError):
|
|
196
|
+
return True
|
|
197
|
+
pid = data.get("pid")
|
|
198
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
199
|
+
return True
|
|
200
|
+
try:
|
|
201
|
+
os.kill(pid, 0)
|
|
202
|
+
except ProcessLookupError:
|
|
203
|
+
return True
|
|
204
|
+
except (PermissionError, OSError):
|
|
205
|
+
return False
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def acquire_active_lock(root: str) -> Path:
|
|
210
|
+
marker, lock = active_paths(root)
|
|
211
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
descriptor = -1
|
|
213
|
+
for _attempt in range(2):
|
|
214
|
+
try:
|
|
215
|
+
descriptor = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
216
|
+
break
|
|
217
|
+
except FileExistsError:
|
|
218
|
+
if not _lock_stale(lock):
|
|
219
|
+
raise ContextError("active_lock_exists", "active.lock", "运行 doctor 检查残留 lock", str(lock)) from None
|
|
220
|
+
try:
|
|
221
|
+
lock.unlink()
|
|
222
|
+
except OSError as exc:
|
|
223
|
+
raise ContextError("active_lock_exists", "active.lock", f"残留 lock 清理失败: {exc}", str(lock)) from exc
|
|
224
|
+
else:
|
|
225
|
+
raise ContextError("active_lock_exists", "active.lock", "残留 lock 无法清理", str(lock))
|
|
226
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
227
|
+
handle.write(json.dumps({"pid": os.getpid(), "marker": str(marker)}))
|
|
228
|
+
return lock
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def release_active_lock(lock: Path) -> None:
|
|
232
|
+
try:
|
|
233
|
+
lock.unlink()
|
|
234
|
+
except FileNotFoundError:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def committed_since(root: str, base_head: Optional[str]) -> Mapping[str, str]:
|
|
239
|
+
"""Paths committed between the frozen baseline head and current HEAD.
|
|
240
|
+
|
|
241
|
+
Returns {} when there is no baseline (pre-commit repo) or the range is
|
|
242
|
+
unresolvable (rewritten/pruned history) — working-tree changes still
|
|
243
|
+
flow through business_changes, so scope never goes fully blind.
|
|
244
|
+
"""
|
|
245
|
+
if not base_head:
|
|
246
|
+
return {}
|
|
247
|
+
try:
|
|
248
|
+
output = cf_git.run_git(root, ("diff", "--name-status", "-z", base_head, "HEAD", "--"))
|
|
249
|
+
except cf_git.GitError:
|
|
250
|
+
return {}
|
|
251
|
+
names: dict[str, str] = {}
|
|
252
|
+
tokens = output.split("\0")
|
|
253
|
+
index = 0
|
|
254
|
+
while index < len(tokens) and tokens[index]:
|
|
255
|
+
status, index = tokens[index], index + 1
|
|
256
|
+
code = status[:1]
|
|
257
|
+
if code in ("R", "C"):
|
|
258
|
+
if index + 1 >= len(tokens):
|
|
259
|
+
break
|
|
260
|
+
new_path, old_path = tokens[index], tokens[index + 1]
|
|
261
|
+
index += 2
|
|
262
|
+
if new_path:
|
|
263
|
+
names[new_path] = "renamed"
|
|
264
|
+
if old_path:
|
|
265
|
+
names[old_path] = "deleted"
|
|
266
|
+
continue
|
|
267
|
+
if index >= len(tokens) or not tokens[index]:
|
|
268
|
+
break
|
|
269
|
+
names[tokens[index]] = {"M": "modified", "A": "added", "D": "deleted"}.get(code, "changed")
|
|
270
|
+
index += 1
|
|
271
|
+
return names
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
|
|
275
|
+
return {
|
|
276
|
+
path: status
|
|
277
|
+
for path, status in git_changes(root).items()
|
|
278
|
+
if not is_excluded(path, excludes)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def new_active(
|
|
283
|
+
root: str,
|
|
284
|
+
task_dir: str,
|
|
285
|
+
task_id: str,
|
|
286
|
+
context_sha256: str,
|
|
287
|
+
owned_preexisting: Sequence[str],
|
|
288
|
+
) -> ActiveTask:
|
|
289
|
+
changes = business_changes(root, DEFAULT_ACTIVE_EXCLUDES)
|
|
290
|
+
owned = tuple(sorted(set(owned_preexisting)))
|
|
291
|
+
unknown = tuple(path for path in owned if path not in changes)
|
|
292
|
+
unowned = tuple(path for path in changes if path not in owned)
|
|
293
|
+
if unknown:
|
|
294
|
+
raise ContextError("unknown_owned_path", "owned_paths", ", ".join(unknown), root)
|
|
295
|
+
if unowned:
|
|
296
|
+
raise ContextError("unowned_changes", "owned_paths", ", ".join(unowned), root)
|
|
297
|
+
# content_sha256 was a per-file hash but is never consumed by any gate (only
|
|
298
|
+
# baseline.head is compared); keep the schema field empty so start never hashes
|
|
299
|
+
# every uncommitted file.
|
|
300
|
+
snapshots = {
|
|
301
|
+
path: PathSnapshot(status, "") for path, status in changes.items()
|
|
302
|
+
}
|
|
303
|
+
head = _git_head(root)
|
|
304
|
+
baseline = ActiveBaseline(head, now_iso(), snapshots, head)
|
|
305
|
+
return ActiveTask(
|
|
306
|
+
1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def start_active_task(
|
|
311
|
+
root: str,
|
|
312
|
+
task_dir: str,
|
|
313
|
+
task_id: str,
|
|
314
|
+
context_sha256: str,
|
|
315
|
+
owned_preexisting: Sequence[str] = (),
|
|
316
|
+
) -> ActiveTask:
|
|
317
|
+
marker = active_paths(root)[0]
|
|
318
|
+
lock = acquire_active_lock(root)
|
|
319
|
+
try:
|
|
320
|
+
if marker.exists():
|
|
321
|
+
raise ContextError("active_exists", "active", "当前 worktree 已有 active TASK", str(marker))
|
|
322
|
+
active = new_active(root, task_dir, task_id, context_sha256, owned_preexisting)
|
|
323
|
+
save_active_task(root, active)
|
|
324
|
+
active = replace(active, status="active")
|
|
325
|
+
save_active_task(root, active)
|
|
326
|
+
return active
|
|
327
|
+
finally:
|
|
328
|
+
release_active_lock(lock)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _transition_active(root: str, expected: Sequence[str], target: str) -> ActiveTask:
|
|
332
|
+
lock = acquire_active_lock(root)
|
|
333
|
+
try:
|
|
334
|
+
active = load_active_task(root)
|
|
335
|
+
if active.status not in expected:
|
|
336
|
+
raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
|
|
337
|
+
current_head = _git_head(root)
|
|
338
|
+
if active.baseline.head != current_head:
|
|
339
|
+
# Mid-task commits are a normal workflow. The baseline head stays
|
|
340
|
+
# frozen so committed work never drops out of scope; only the
|
|
341
|
+
# observation field advances for diagnostics.
|
|
342
|
+
active = replace(active, baseline=replace(active.baseline, last_seen_head=current_head))
|
|
343
|
+
updated = replace(active, status=target)
|
|
344
|
+
save_active_task(root, updated)
|
|
345
|
+
return updated
|
|
346
|
+
finally:
|
|
347
|
+
release_active_lock(lock)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def pause_active_task(root: str) -> ActiveTask:
|
|
351
|
+
return _transition_active(root, ("active",), "paused")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def block_active_task(root: str) -> ActiveTask:
|
|
355
|
+
return _transition_active(root, ("active",), "blocked")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def resume_active_task(root: str) -> ActiveTask:
|
|
359
|
+
return _transition_active(root, ("paused", "blocked"), "active")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
|
|
363
|
+
changes = business_changes(root, active.excluded_paths)
|
|
364
|
+
committed = {
|
|
365
|
+
path: status
|
|
366
|
+
for path, status in committed_since(root, active.baseline.head).items()
|
|
367
|
+
if not is_excluded(path, active.excluded_paths)
|
|
368
|
+
}
|
|
369
|
+
return tuple(sorted(set(active.owned_paths).union(changes).union(committed)))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
|
|
373
|
+
if not gate_passed:
|
|
374
|
+
raise ContextError("gate_failed", "gate", "Done Gate 未通过", root)
|
|
375
|
+
completed = _transition_active(root, ("active",), "completed")
|
|
376
|
+
marker = active_paths(root)[0]
|
|
377
|
+
try:
|
|
378
|
+
marker.unlink()
|
|
379
|
+
except OSError as exc:
|
|
380
|
+
raise ContextError("active_cleanup_failed", "active", str(exc), str(marker)) from exc
|
|
381
|
+
return completed
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def doctor_active_task(
|
|
385
|
+
root: str, expected_context_sha256: str, abandon: bool = False, resync: bool = False
|
|
386
|
+
) -> DoctorResult:
|
|
387
|
+
marker, lock = active_paths(root)
|
|
388
|
+
try:
|
|
389
|
+
active = load_active_task(root)
|
|
390
|
+
except ContextError as exc:
|
|
391
|
+
raise ContextError("recovery_required", "active", exc.message, str(marker)) from exc
|
|
392
|
+
if abandon:
|
|
393
|
+
marker.unlink()
|
|
394
|
+
release_active_lock(lock)
|
|
395
|
+
return DoctorResult("abandoned", replace(active, status="completed"))
|
|
396
|
+
if resync:
|
|
397
|
+
# Self-heal: the context file is authoritative and the marker hash is a
|
|
398
|
+
# cache. Accept the current context when the marker is the only
|
|
399
|
+
# inconsistency; bind/decision/refresh already re-sync automatically,
|
|
400
|
+
# so this covers legacy markers and manual context edits.
|
|
401
|
+
repaired = replace(active, status="active", context_sha256=expected_context_sha256)
|
|
402
|
+
save_active_task(root, repaired)
|
|
403
|
+
release_active_lock(lock)
|
|
404
|
+
return DoctorResult("resynced", repaired)
|
|
405
|
+
proven = active.context_sha256 == expected_context_sha256
|
|
406
|
+
proven = proven and active.baseline.head == _git_head(root)
|
|
407
|
+
if active.status != "activating" or not proven:
|
|
408
|
+
raise ContextError("recovery_required", "active", "状态或 hash 无法证明可自动恢复", str(marker))
|
|
409
|
+
repaired = replace(active, status="active")
|
|
410
|
+
save_active_task(root, repaired)
|
|
411
|
+
release_active_lock(lock)
|
|
412
|
+
return DoctorResult("resumed", repaired)
|