@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,1175 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Persisted Spec Context CLI: binding, decisions, refresh and doctor.
|
|
3
|
+
|
|
4
|
+
The pure model, serialization and identity hash live in
|
|
5
|
+
`codeflow.spec.cf_spec_context_model`; the active marker, lock and Git
|
|
6
|
+
ownership scope live in `codeflow.spec.cf_active_task`. This module keeps the
|
|
7
|
+
CLI entry, doctor and decision orchestration.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from typing import IO, Mapping, Optional, Sequence
|
|
21
|
+
|
|
22
|
+
from codeflow.spec.cf_active_task import ( # re-exported context API
|
|
23
|
+
ACTIVE_STATUSES,
|
|
24
|
+
ActiveBaseline,
|
|
25
|
+
ActiveTask,
|
|
26
|
+
DEFAULT_ACTIVE_EXCLUDES,
|
|
27
|
+
DoctorResult,
|
|
28
|
+
PathSnapshot,
|
|
29
|
+
active_data,
|
|
30
|
+
active_paths,
|
|
31
|
+
block_active_task,
|
|
32
|
+
business_changes as _business_changes,
|
|
33
|
+
complete_active_task,
|
|
34
|
+
current_owned_paths,
|
|
35
|
+
doctor_active_task,
|
|
36
|
+
git_changes as _git_changes,
|
|
37
|
+
load_active_task,
|
|
38
|
+
pause_active_task,
|
|
39
|
+
resume_active_task,
|
|
40
|
+
save_active_task,
|
|
41
|
+
start_active_task,
|
|
42
|
+
)
|
|
43
|
+
from codeflow.spec.cf_spec_context_model import ( # re-exported context API
|
|
44
|
+
ArtifactRef,
|
|
45
|
+
BindingInput,
|
|
46
|
+
ContextError,
|
|
47
|
+
ContextSource,
|
|
48
|
+
Decision,
|
|
49
|
+
DriftChange,
|
|
50
|
+
DriftResult,
|
|
51
|
+
RuleBinding,
|
|
52
|
+
RuleStageStatus,
|
|
53
|
+
SpecBinding,
|
|
54
|
+
SpecContext,
|
|
55
|
+
context_sha256,
|
|
56
|
+
decision_from_data,
|
|
57
|
+
error_payload,
|
|
58
|
+
load_context,
|
|
59
|
+
mapping_field,
|
|
60
|
+
new_context,
|
|
61
|
+
now_iso,
|
|
62
|
+
save_context,
|
|
63
|
+
sequence_field,
|
|
64
|
+
string_field,
|
|
65
|
+
validate_decision,
|
|
66
|
+
)
|
|
67
|
+
from codeflow.spec.cf_spec_metadata import SpecHashes, SpecMetadata, SpecRule, load_spec_metadata
|
|
68
|
+
from codeflow.spec.cf_context_refresh import (
|
|
69
|
+
FILE_HASH_CACHE as _FILE_HASH_CACHE, bind_specs, diff_spec_hashes,
|
|
70
|
+
file_sha256 as _file_sha256, refresh_context, refresh_missing_specs,
|
|
71
|
+
)
|
|
72
|
+
from codeflow.spec.cf_spec_resolver import SpecCandidate, resolve_candidates
|
|
73
|
+
from codeflow.task.cf_task_doc import TaskDocumentError, task_section
|
|
74
|
+
from codeflow.core.cf_core import phase_timing
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def task_contract_digest(root: str, task_dir: str, task_id: str) -> str:
|
|
78
|
+
"""Legacy version digest of TASK Spec/Design refs and acceptance inputs.
|
|
79
|
+
|
|
80
|
+
Two TASKs may share one Context hash while owning different scenarios; the
|
|
81
|
+
digest separates their injection versions so switching TASKs re-injects.
|
|
82
|
+
Returns "" when the TASK section cannot be located (fail-open to the
|
|
83
|
+
legacy context-hash-only key rather than crashing the hook hot path).
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
files = sorted((Path(root) / task_dir).glob("*.md"))
|
|
87
|
+
except OSError:
|
|
88
|
+
return ""
|
|
89
|
+
for path in files:
|
|
90
|
+
if path.name.endswith((".prd.md", ".design.md")):
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
text = path.read_text(encoding="utf-8")
|
|
94
|
+
except (OSError, UnicodeError):
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
section = task_section(text, task_id, source=str(path))
|
|
98
|
+
except TaskDocumentError as exc:
|
|
99
|
+
if exc.code in ("task_document_empty", "task_not_found"):
|
|
100
|
+
continue
|
|
101
|
+
return ""
|
|
102
|
+
body = section.body
|
|
103
|
+
chunks: list[str] = []
|
|
104
|
+
for heading in ("Spec-Refs", "Source", "Design-Refs", "Acceptance-Refs", "Acceptance Contract", "Acceptance Evidence"):
|
|
105
|
+
index = body.find(heading)
|
|
106
|
+
if index == -1:
|
|
107
|
+
chunks.append(f"{heading}:missing")
|
|
108
|
+
continue
|
|
109
|
+
end = body.find("\n### ", index)
|
|
110
|
+
chunks.append(body[index:] if end == -1 else body[index:end])
|
|
111
|
+
return hashlib.sha256("\n".join(chunks).encode("utf-8")).hexdigest()
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def injection_version(root: str, session_id: str, context_sha: str) -> str:
|
|
116
|
+
"""Version key for hook injection dedup.
|
|
117
|
+
|
|
118
|
+
Covers session + task_dir + task_id + context hash + TASK contract digest,
|
|
119
|
+
so a TASK switch (or an in-place contract edit) always re-injects while a
|
|
120
|
+
repeated prompt on the same contract stays suppressed.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
active = load_active_task(root)
|
|
124
|
+
except (ContextError, OSError, ValueError):
|
|
125
|
+
return f"none:{session_id}:{context_sha}"
|
|
126
|
+
digest = task_contract_digest(root, active.task_dir, active.task_id)
|
|
127
|
+
raw = "\n".join((session_id, active.task_dir, active.task_id, context_sha, digest))
|
|
128
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resync_active_hash(root: str, task_dir: str, current_sha256: str) -> bool:
|
|
132
|
+
"""Re-sync the active marker's context hash after toolchain-initiated
|
|
133
|
+
context edits (bind/decision/refresh) so they never surface as
|
|
134
|
+
unrecoverable active_context_drift. No-op without an active marker or when
|
|
135
|
+
the hash already matches; never raises on a missing marker.
|
|
136
|
+
|
|
137
|
+
Only the active demand may re-sync: editing another demand's context must
|
|
138
|
+
never overwrite the marker hash (cross-demand drift).
|
|
139
|
+
"""
|
|
140
|
+
marker = Path(root) / ".code-flow" / ".active-task.json"
|
|
141
|
+
if not marker.exists():
|
|
142
|
+
return False
|
|
143
|
+
active = load_active_task(root)
|
|
144
|
+
requested = Path(task_dir)
|
|
145
|
+
requested = (Path(root) / requested if not requested.is_absolute() else requested).resolve()
|
|
146
|
+
active_dir = Path(active.task_dir)
|
|
147
|
+
active_dir = (Path(root) / active_dir if not active_dir.is_absolute() else active_dir).resolve()
|
|
148
|
+
if active_dir != requested:
|
|
149
|
+
return False
|
|
150
|
+
if active.context_sha256 == current_sha256:
|
|
151
|
+
return False
|
|
152
|
+
save_active_task(root, replace(active, context_sha256=current_sha256))
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _find_root(task_dir: str) -> Optional[str]:
|
|
157
|
+
"""Walk up from a task dir to the project root that owns its marker."""
|
|
158
|
+
current = Path(task_dir).resolve()
|
|
159
|
+
for candidate in (current, *current.parents):
|
|
160
|
+
if (candidate / ".code-flow" / ".active-task.json").exists():
|
|
161
|
+
return str(candidate)
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _resync_after_save(args: argparse.Namespace, context_path: Path) -> bool:
|
|
166
|
+
"""Post-save marker re-sync used by CLI commands that persist the context."""
|
|
167
|
+
root = getattr(args, "root", "") or _find_root(str(context_path.parent)) or ""
|
|
168
|
+
if not root:
|
|
169
|
+
return False
|
|
170
|
+
current = context_sha256(load_context(str(context_path)))
|
|
171
|
+
return resync_active_hash(root, str(context_path.parent), current)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def apply_decision(
|
|
175
|
+
context: SpecContext,
|
|
176
|
+
spec_id: str,
|
|
177
|
+
rule_ref: str,
|
|
178
|
+
stage: str,
|
|
179
|
+
decision: Decision,
|
|
180
|
+
batch: bool = False,
|
|
181
|
+
) -> SpecContext:
|
|
182
|
+
if batch:
|
|
183
|
+
raise ContextError("batch_confirmation_forbidden", "batch", "N/A/豁免必须逐项确认")
|
|
184
|
+
validate_decision(decision)
|
|
185
|
+
target_status = {"not_applicable": "not_applicable", "waived": "waived", "manual_verification": "verified"}[decision.kind]
|
|
186
|
+
found = False
|
|
187
|
+
bindings: list[SpecBinding] = []
|
|
188
|
+
for binding in context.bindings:
|
|
189
|
+
changed_rules: list[RuleBinding] = []
|
|
190
|
+
for rule in binding.rules:
|
|
191
|
+
if binding.spec_id == spec_id and rule.ref == rule_ref:
|
|
192
|
+
if stage not in rule.stage_status:
|
|
193
|
+
raise ContextError("unknown_stage", "stage", f"Rule 不适用于 {stage}")
|
|
194
|
+
statuses = dict(rule.stage_status)
|
|
195
|
+
statuses[stage] = replace(statuses[stage], status=target_status, decision=decision)
|
|
196
|
+
rule = replace(rule, stage_status=statuses)
|
|
197
|
+
found = True
|
|
198
|
+
changed_rules.append(rule)
|
|
199
|
+
bindings.append(replace(binding, rules=tuple(changed_rules)))
|
|
200
|
+
if not found:
|
|
201
|
+
raise ContextError("unknown_rule", "rule_ref", f"未找到 {spec_id}#{rule_ref}")
|
|
202
|
+
return replace(context, updated_at=now_iso(), bindings=tuple(bindings))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _upsert_artifact_ref(
|
|
206
|
+
references: Sequence[ArtifactRef], replacement: ArtifactRef
|
|
207
|
+
) -> tuple[ArtifactRef, ...]:
|
|
208
|
+
identity = (replacement.artifact, replacement.section_id, replacement.item_id)
|
|
209
|
+
updated: list[ArtifactRef] = []
|
|
210
|
+
replaced = False
|
|
211
|
+
for reference in references:
|
|
212
|
+
current = (reference.artifact, reference.section_id, reference.item_id)
|
|
213
|
+
if current != identity:
|
|
214
|
+
updated.append(reference)
|
|
215
|
+
elif not replaced:
|
|
216
|
+
updated.append(replacement)
|
|
217
|
+
replaced = True
|
|
218
|
+
if not replaced:
|
|
219
|
+
updated.append(replacement)
|
|
220
|
+
return tuple(updated)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def apply_artifact_ref(
|
|
224
|
+
context: SpecContext,
|
|
225
|
+
spec_id: str,
|
|
226
|
+
rule_ref: str,
|
|
227
|
+
stage: str,
|
|
228
|
+
reference: ArtifactRef,
|
|
229
|
+
) -> SpecContext:
|
|
230
|
+
found = False
|
|
231
|
+
bindings: list[SpecBinding] = []
|
|
232
|
+
for binding in context.bindings:
|
|
233
|
+
rules: list[RuleBinding] = []
|
|
234
|
+
for rule in binding.rules:
|
|
235
|
+
if binding.spec_id == spec_id and rule.ref == rule_ref:
|
|
236
|
+
if stage not in rule.stage_status:
|
|
237
|
+
raise ContextError("unknown_stage", "stage", f"Rule 不适用于 {stage}")
|
|
238
|
+
statuses = dict(rule.stage_status)
|
|
239
|
+
current = statuses[stage]
|
|
240
|
+
refs = _upsert_artifact_ref(current.refs, reference)
|
|
241
|
+
if current.decision is not None:
|
|
242
|
+
statuses[stage] = replace(current, refs=refs)
|
|
243
|
+
else:
|
|
244
|
+
statuses[stage] = replace(current, status="applied", refs=refs)
|
|
245
|
+
rule = replace(rule, stage_status=statuses)
|
|
246
|
+
found = True
|
|
247
|
+
rules.append(rule)
|
|
248
|
+
bindings.append(replace(binding, rules=tuple(rules)))
|
|
249
|
+
if not found:
|
|
250
|
+
raise ContextError("unknown_rule", "rule_ref", f"未找到 {spec_id}#{rule_ref}")
|
|
251
|
+
return replace(context, updated_at=now_iso(), bindings=tuple(bindings))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _json_payload(stream: IO[str]) -> Mapping[str, object]:
|
|
255
|
+
try:
|
|
256
|
+
raw = stream.read()
|
|
257
|
+
except (OSError, UnicodeError) as exc:
|
|
258
|
+
raise ContextError("invalid_json", "stdin", str(exc)) from exc
|
|
259
|
+
if not raw.strip():
|
|
260
|
+
# 空 stdin = 无 payload(例如 active doctor --resync 全参数走 CLI 开关)。
|
|
261
|
+
return {}
|
|
262
|
+
try:
|
|
263
|
+
loaded = json.loads(raw)
|
|
264
|
+
except json.JSONDecodeError as exc:
|
|
265
|
+
raise ContextError("invalid_json", "stdin", str(exc)) from exc
|
|
266
|
+
return mapping_field(loaded, "stdin", "")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _candidate_data(candidate: SpecCandidate) -> dict[str, object]:
|
|
270
|
+
return {
|
|
271
|
+
"spec_id": candidate.spec_id,
|
|
272
|
+
"path": candidate.path,
|
|
273
|
+
"scope": candidate.scope,
|
|
274
|
+
"priority": candidate.priority,
|
|
275
|
+
"matched_paths": list(candidate.matched_paths),
|
|
276
|
+
"description": candidate.metadata.description,
|
|
277
|
+
"enforcement": candidate.metadata.enforcement,
|
|
278
|
+
"stages": list(candidate.metadata.stages),
|
|
279
|
+
"hashes": {
|
|
280
|
+
"file_sha256": candidate.metadata.hashes.file_sha256,
|
|
281
|
+
"metadata_sha256": candidate.metadata.hashes.metadata_sha256,
|
|
282
|
+
"rules_sha256": candidate.metadata.hashes.rules_sha256,
|
|
283
|
+
},
|
|
284
|
+
"rules": [
|
|
285
|
+
{"ref": rule.ref, "text": rule.text, "enforcement": rule.enforcement}
|
|
286
|
+
for rule in candidate.metadata.rules
|
|
287
|
+
],
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _catalog_command(args: argparse.Namespace) -> dict[str, object]:
|
|
292
|
+
from codeflow.core.cf_core import CatalogBudgetError, build_effective_mapping, catalog_omissions, load_config
|
|
293
|
+
config = load_config(args.root)
|
|
294
|
+
budget = config.get("budget") or {}
|
|
295
|
+
if not isinstance(budget, dict):
|
|
296
|
+
raise ValueError("budget must be a mapping")
|
|
297
|
+
maximum = int(budget.get("catalog_max", 200))
|
|
298
|
+
try:
|
|
299
|
+
omissions = catalog_omissions(args.root, build_effective_mapping(args.root, config.get("path_mapping") or {}), maximum)
|
|
300
|
+
except CatalogBudgetError as exc:
|
|
301
|
+
return {"ok": False, "error": {"code": "catalog_budget_too_small", "minimum": exc.minimum}}
|
|
302
|
+
candidates = resolve_candidates(args.root, args.stage, tuple(args.paths))
|
|
303
|
+
return {
|
|
304
|
+
"ok": True,
|
|
305
|
+
"stage": args.stage,
|
|
306
|
+
"total": len(candidates),
|
|
307
|
+
"candidates": [_candidate_data(item) for item in candidates],
|
|
308
|
+
**omissions,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _application_ref(task_dir: str, value: Mapping[str, object]) -> ArtifactRef:
|
|
313
|
+
artifact = string_field(value.get("artifact"), "applications[].artifact", "")
|
|
314
|
+
base = Path(task_dir).resolve()
|
|
315
|
+
target = (base / artifact).resolve()
|
|
316
|
+
if base != target and base not in target.parents:
|
|
317
|
+
raise ContextError("artifact_outside_task", "applications[].artifact", artifact, task_dir)
|
|
318
|
+
from codeflow.spec.cf_rule_readiness import artifact_problem
|
|
319
|
+
section = string_field(value.get("section_id"), "applications[].section_id", "")
|
|
320
|
+
item = string_field(value.get("item_id"), "applications[].item_id", "")
|
|
321
|
+
problem = artifact_problem(str(base), artifact, section, item)
|
|
322
|
+
if problem:
|
|
323
|
+
raise ContextError(problem, "applications[]", f"{artifact}#{section}/{item}", task_dir)
|
|
324
|
+
try:
|
|
325
|
+
artifact_hash = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
326
|
+
except OSError as exc:
|
|
327
|
+
raise ContextError("artifact_read_error", "applications[].artifact", str(exc), str(target)) from exc
|
|
328
|
+
return ArtifactRef(
|
|
329
|
+
artifact,
|
|
330
|
+
section,
|
|
331
|
+
item,
|
|
332
|
+
artifact_hash,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _apply_payload(context: SpecContext, task_dir: str, payload: Mapping[str, object]) -> SpecContext:
|
|
337
|
+
updated = context
|
|
338
|
+
for raw in sequence_field(payload.get("applications", []), "applications", ""):
|
|
339
|
+
item = mapping_field(raw, "applications[]", "")
|
|
340
|
+
updated = apply_artifact_ref(
|
|
341
|
+
updated,
|
|
342
|
+
string_field(item.get("spec_id"), "applications[].spec_id", ""),
|
|
343
|
+
string_field(item.get("rule_ref"), "applications[].rule_ref", ""),
|
|
344
|
+
string_field(item.get("stage"), "applications[].stage", ""),
|
|
345
|
+
_application_ref(task_dir, item),
|
|
346
|
+
)
|
|
347
|
+
return updated
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _decision_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
351
|
+
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
352
|
+
decision = decision_from_data(payload.get("decision"), str(context_path))
|
|
353
|
+
if decision is None:
|
|
354
|
+
raise ContextError("invalid_decision", "decision", "必须提供 decision")
|
|
355
|
+
context = apply_decision(
|
|
356
|
+
load_context(str(context_path)),
|
|
357
|
+
string_field(payload.get("spec_id"), "spec_id", ""),
|
|
358
|
+
string_field(payload.get("rule_ref"), "rule_ref", ""),
|
|
359
|
+
string_field(payload.get("stage"), "stage", ""),
|
|
360
|
+
decision,
|
|
361
|
+
payload.get("batch") is True,
|
|
362
|
+
)
|
|
363
|
+
save_context(str(context_path), context)
|
|
364
|
+
_resync_after_save(args, context_path)
|
|
365
|
+
# Report the status of the rule the decision targeted, not the first binding
|
|
366
|
+
# (bindings[0].rules[0] previously returned an unrelated rule).
|
|
367
|
+
spec_id = string_field(payload.get("spec_id"), "spec_id", "")
|
|
368
|
+
rule_ref = string_field(payload.get("rule_ref"), "rule_ref", "")
|
|
369
|
+
stage = string_field(payload.get("stage"), "stage", "")
|
|
370
|
+
status = "unknown"
|
|
371
|
+
for binding in context.bindings:
|
|
372
|
+
if binding.spec_id != spec_id:
|
|
373
|
+
continue
|
|
374
|
+
for rule in binding.rules:
|
|
375
|
+
if rule.ref == rule_ref:
|
|
376
|
+
stage_status = rule.stage_status.get(stage)
|
|
377
|
+
status = stage_status.status if stage_status is not None else "missing"
|
|
378
|
+
return {"ok": True, "status": status}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
382
|
+
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
383
|
+
paths = tuple(string_field(item, "paths[]", "") for item in sequence_field(payload.get("paths", []), "paths", ""))
|
|
384
|
+
candidates = {item.spec_id: item for item in resolve_candidates(args.root, args.stage, paths)}
|
|
385
|
+
selections: list[BindingInput] = []
|
|
386
|
+
for raw in sequence_field(payload.get("selections"), "selections", ""):
|
|
387
|
+
item = mapping_field(raw, "selections[]", "")
|
|
388
|
+
spec_id = string_field(item.get("spec_id"), "selections[].spec_id", "")
|
|
389
|
+
if spec_id not in candidates:
|
|
390
|
+
raise ContextError("unknown_candidate", "selections[].spec_id", spec_id)
|
|
391
|
+
selections.append(
|
|
392
|
+
BindingInput(
|
|
393
|
+
candidates[spec_id],
|
|
394
|
+
string_field(item.get("selected_by"), "selections[].selected_by", ""),
|
|
395
|
+
string_field(item.get("reason"), "selections[].reason", ""),
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
if context_path.exists():
|
|
399
|
+
context = load_context(str(context_path))
|
|
400
|
+
else:
|
|
401
|
+
task = string_field(payload.get("task"), "task", "")
|
|
402
|
+
context = new_context(task, (("cli", "bind"),))
|
|
403
|
+
context = bind_specs(context, selections)
|
|
404
|
+
context = _apply_payload(context, args.task_dir, payload)
|
|
405
|
+
save_context(str(context_path), context)
|
|
406
|
+
_resync_after_save(args, context_path)
|
|
407
|
+
applied = sum(
|
|
408
|
+
status.status == "applied"
|
|
409
|
+
for binding in context.bindings
|
|
410
|
+
for rule in binding.rules
|
|
411
|
+
for status in rule.stage_status.values()
|
|
412
|
+
)
|
|
413
|
+
return {"ok": True, "bindings": len(context.bindings), "applied": applied}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
417
|
+
from codeflow.spec.cf_workflow_service import WorkflowError, locate_task_file
|
|
418
|
+
from codeflow.spec.cf_workflow_transaction import recover_transition
|
|
419
|
+
|
|
420
|
+
recover_transition(args.root)
|
|
421
|
+
if args.active_action in ("pause", "resume", "block", "complete"):
|
|
422
|
+
marker = active_paths(args.root)[0]
|
|
423
|
+
if marker.exists():
|
|
424
|
+
current = load_active_task(args.root)
|
|
425
|
+
if (current.task_id != args.task or
|
|
426
|
+
(Path(args.root) / current.task_dir).resolve() != (Path(args.root) / args.task_dir).resolve()):
|
|
427
|
+
raise ContextError("active_mismatch", "active", "requested TASK does not match marker", str(marker))
|
|
428
|
+
|
|
429
|
+
def _directory() -> str:
|
|
430
|
+
return args.task_dir if Path(args.task_dir).is_absolute() else str(Path(args.root) / args.task_dir)
|
|
431
|
+
|
|
432
|
+
def _reraise(exc: WorkflowError) -> ContextError:
|
|
433
|
+
return ContextError(exc.code, "workflow", str(exc), args.task_dir)
|
|
434
|
+
|
|
435
|
+
task_file = locate_task_file(_directory(), args.task)
|
|
436
|
+
if args.active_action == "start":
|
|
437
|
+
owned = tuple(
|
|
438
|
+
string_field(item, "owned_paths[]", "")
|
|
439
|
+
for item in sequence_field(payload.get("owned_paths", []), "owned_paths", "")
|
|
440
|
+
)
|
|
441
|
+
if task_file is None:
|
|
442
|
+
active = start_active_task(
|
|
443
|
+
args.root, args.task_dir, args.task, args.context_sha256, owned
|
|
444
|
+
)
|
|
445
|
+
return {"ok": True, "active": active_data(active)}
|
|
446
|
+
from codeflow.spec.cf_workflow_service import start_task
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
return start_task(args.root, args.task_dir, str(task_file), args.task, owned)
|
|
450
|
+
except WorkflowError as exc:
|
|
451
|
+
raise _reraise(exc) from exc
|
|
452
|
+
if args.active_action == "pause":
|
|
453
|
+
active = pause_active_task(args.root)
|
|
454
|
+
elif args.active_action == "resume":
|
|
455
|
+
if task_file is None:
|
|
456
|
+
active = resume_active_task(args.root)
|
|
457
|
+
else:
|
|
458
|
+
from codeflow.spec.cf_workflow_service import resume_task
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
return resume_task(args.root, _directory(), str(task_file), args.task)
|
|
462
|
+
except WorkflowError as exc:
|
|
463
|
+
raise _reraise(exc) from exc
|
|
464
|
+
elif args.active_action == "block":
|
|
465
|
+
if task_file is None:
|
|
466
|
+
active = block_active_task(args.root)
|
|
467
|
+
else:
|
|
468
|
+
from codeflow.spec.cf_workflow_service import block_task
|
|
469
|
+
|
|
470
|
+
reason = payload.get("reason", "")
|
|
471
|
+
try:
|
|
472
|
+
return block_task(args.root, _directory(), str(task_file), args.task, reason if isinstance(reason, str) else "")
|
|
473
|
+
except WorkflowError as exc:
|
|
474
|
+
raise _reraise(exc) from exc
|
|
475
|
+
elif args.active_action == "complete":
|
|
476
|
+
gate = payload.get("gate_passed") is True
|
|
477
|
+
if task_file is None:
|
|
478
|
+
active = complete_active_task(args.root, gate)
|
|
479
|
+
else:
|
|
480
|
+
from codeflow.spec.cf_workflow_service import complete_task
|
|
481
|
+
|
|
482
|
+
try:
|
|
483
|
+
return complete_task(args.root, _directory(), str(task_file), args.task, gate)
|
|
484
|
+
except WorkflowError as exc:
|
|
485
|
+
raise _reraise(exc) from exc
|
|
486
|
+
else:
|
|
487
|
+
abandon = payload.get("abandon") is True or args.abandon
|
|
488
|
+
resync = payload.get("resync") is True or args.resync
|
|
489
|
+
if abandon and resync:
|
|
490
|
+
raise ContextError("conflicting_recovery", "doctor", "abandon 与 resync 不能同时使用", args.task_dir)
|
|
491
|
+
result = doctor_active_task(args.root, args.context_sha256, abandon, resync)
|
|
492
|
+
return {"ok": True, "action": result.action, "active": active_data(result.active)}
|
|
493
|
+
return {"ok": True, "active": active_data(active)}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _start_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
497
|
+
"""Run refresh, activation and TASK projection in one guarded process.
|
|
498
|
+
|
|
499
|
+
Delegates to the unified workflow service: hard preconditions (status,
|
|
500
|
+
NOTES, depends, marker) are enforced before any state changes, and the
|
|
501
|
+
Markdown view flips to in-progress in the same call.
|
|
502
|
+
"""
|
|
503
|
+
from codeflow.spec.cf_workflow_service import WorkflowError, start_task
|
|
504
|
+
|
|
505
|
+
owned = tuple(
|
|
506
|
+
string_field(item, "owned_paths[]", "")
|
|
507
|
+
for item in sequence_field(payload.get("owned_paths", []), "owned_paths", "")
|
|
508
|
+
)
|
|
509
|
+
try:
|
|
510
|
+
return start_task(
|
|
511
|
+
args.root, args.task_dir, args.task_file, args.task, owned, args.session_output
|
|
512
|
+
)
|
|
513
|
+
except WorkflowError as exc:
|
|
514
|
+
raise ContextError(exc.code, "workflow", str(exc), args.task_dir) from exc
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _status_command(args: argparse.Namespace) -> dict[str, object]:
|
|
518
|
+
"""Human-readable Spec Context status: task, marker health, gate, rules."""
|
|
519
|
+
from codeflow.spec.cf_stage_gate import result_to_data, validate_stage
|
|
520
|
+
|
|
521
|
+
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
522
|
+
context = load_context(str(context_path))
|
|
523
|
+
gate = validate_stage(context, "code")
|
|
524
|
+
root = getattr(args, "root", "") or _find_root(args.task_dir) or ""
|
|
525
|
+
marker: dict[str, object] = {"exists": False}
|
|
526
|
+
if root:
|
|
527
|
+
marker_path = Path(root) / ".code-flow" / ".active-task.json"
|
|
528
|
+
if marker_path.exists():
|
|
529
|
+
active = load_active_task(root)
|
|
530
|
+
marker = {
|
|
531
|
+
"exists": True,
|
|
532
|
+
"task_id": active.task_id,
|
|
533
|
+
"status": active.status,
|
|
534
|
+
"hash_match": active.context_sha256 == context_sha256(context),
|
|
535
|
+
"baseline_head": active.baseline.head,
|
|
536
|
+
}
|
|
537
|
+
bindings = []
|
|
538
|
+
for binding in context.bindings:
|
|
539
|
+
rules = []
|
|
540
|
+
for rule in binding.rules:
|
|
541
|
+
status = rule.stage_status.get("code")
|
|
542
|
+
stages = {stage: item.status for stage, item in sorted(rule.stage_status.items())}
|
|
543
|
+
rules.append(
|
|
544
|
+
{
|
|
545
|
+
"ref": f"{binding.spec_id}#{rule.ref}",
|
|
546
|
+
"enforcement": rule.enforcement,
|
|
547
|
+
"status": status.status if status else "missing",
|
|
548
|
+
"stages": stages,
|
|
549
|
+
"verifier": rule.verifier_ref,
|
|
550
|
+
}
|
|
551
|
+
)
|
|
552
|
+
bindings.append({"spec_id": binding.spec_id, "path": binding.path, "status": binding.status, "rules": rules})
|
|
553
|
+
return {
|
|
554
|
+
"task": context.task,
|
|
555
|
+
"context_sha256": context_sha256(context),
|
|
556
|
+
"gate": result_to_data(gate),
|
|
557
|
+
"marker": marker,
|
|
558
|
+
"bindings": bindings,
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _status_text(data: dict[str, object]) -> str:
|
|
563
|
+
lines = [f"# {data['task']} — Spec Context 状态"]
|
|
564
|
+
marker = data["marker"]
|
|
565
|
+
if marker["exists"]:
|
|
566
|
+
match = "✓ 一致" if marker["hash_match"] else "✗ 漂移"
|
|
567
|
+
lines.append(f"- TASK {marker['task_id']}({marker['status']}),marker hash {match}")
|
|
568
|
+
if not marker["hash_match"]:
|
|
569
|
+
lines.append(" 下一步: 运行 cf-spec refresh 自动重同步 marker hash;仍不一致时用 active doctor --resync(hash 取本命令 --json 的 context_sha256)")
|
|
570
|
+
else:
|
|
571
|
+
lines.append("- 无 active TASK(path/catalog 路由模式)")
|
|
572
|
+
gate = data["gate"]
|
|
573
|
+
if gate["decision"] == "pass":
|
|
574
|
+
lines.append("- code Gate: ✓ pass")
|
|
575
|
+
else:
|
|
576
|
+
lines.append(f"- code Gate: ✗ block({len(gate['errors'])} 项)")
|
|
577
|
+
for issue in gate["errors"]:
|
|
578
|
+
lines.append(f" ✗ {issue['message']}")
|
|
579
|
+
for binding in data["bindings"]:
|
|
580
|
+
lines.append(f"- {binding['spec_id']}({binding['path']})— {binding['status']}")
|
|
581
|
+
for rule in binding["rules"]:
|
|
582
|
+
stage_text = " ".join(f"{stage}={value}" for stage, value in rule.get("stages", {}).items())
|
|
583
|
+
lines.append(f" {stage_text:<24} {rule['ref']}({rule['verifier']})")
|
|
584
|
+
return "\n".join(lines)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _doctor_finding(code: str, severity: str, message: str, path: str = "", fix: str = "") -> dict[str, str]:
|
|
588
|
+
return {"code": code, "severity": severity, "message": message, "path": path, "fix": fix}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _doctor_runtime(root: Path) -> tuple[dict[str, object], list[dict[str, str]]]:
|
|
592
|
+
"""Diagnose install manifest drift without mutating the runtime."""
|
|
593
|
+
runtime: dict[str, object] = {
|
|
594
|
+
"installed": False, "version": "", "manifest_version": "",
|
|
595
|
+
"files_checked": 0, "files_missing": [], "files_drifted": [], "commands": 0,
|
|
596
|
+
}
|
|
597
|
+
version_path = root / ".code-flow/.version"
|
|
598
|
+
manifest_path = root / ".code-flow/.runtime-install.json"
|
|
599
|
+
if not version_path.exists() and not manifest_path.exists():
|
|
600
|
+
return runtime, [_doctor_finding(
|
|
601
|
+
"runtime_not_installed", "info", "未检测到运行时安装清单", str(manifest_path),
|
|
602
|
+
"code-flow init 或 code-flow migrate --runtime --apply")]
|
|
603
|
+
runtime["installed"] = True
|
|
604
|
+
if not version_path.is_file():
|
|
605
|
+
return runtime, [_doctor_finding(
|
|
606
|
+
"runtime_version_missing", "block", "缺少 .code-flow/.version", str(version_path),
|
|
607
|
+
"code-flow migrate --runtime --apply")]
|
|
608
|
+
version = version_path.read_text(encoding="utf-8").strip()
|
|
609
|
+
runtime["version"] = version
|
|
610
|
+
if not _valid_version(version):
|
|
611
|
+
return runtime, [_doctor_finding(
|
|
612
|
+
"runtime_version_invalid", "block", f"运行时不合法: {version!r}", str(version_path),
|
|
613
|
+
"code-flow migrate --runtime --apply")]
|
|
614
|
+
findings = _doctor_manifest(root, manifest_path, version, runtime)
|
|
615
|
+
findings.extend(_doctor_contract(root, runtime))
|
|
616
|
+
package_version = _package_version(root)
|
|
617
|
+
if package_version:
|
|
618
|
+
runtime["cli_version"] = package_version
|
|
619
|
+
if package_version != version:
|
|
620
|
+
upgrade = tuple(map(int, package_version.split("."))) < tuple(map(int, version.split(".")))
|
|
621
|
+
repair = (f"npm install --global @michaelxwb/code-flow@{version}" if upgrade
|
|
622
|
+
else "code-flow migrate --runtime --dry-run")
|
|
623
|
+
findings.append(_doctor_finding(
|
|
624
|
+
"runtime_cli_version_mismatch", "warn",
|
|
625
|
+
f"CLI 包版本 {package_version} 与运行时版本 {version} 不一致",
|
|
626
|
+
str(root / "package.json"), repair))
|
|
627
|
+
return runtime, findings
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _package_version(root: Path) -> str:
|
|
631
|
+
cli_version = os.environ.get("CF_CLI_VERSION", "")
|
|
632
|
+
if _valid_version(cli_version):
|
|
633
|
+
return cli_version
|
|
634
|
+
path = root / "package.json"
|
|
635
|
+
if not path.is_file():
|
|
636
|
+
return ""
|
|
637
|
+
try:
|
|
638
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
639
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
640
|
+
return ""
|
|
641
|
+
if not isinstance(data, Mapping) or data.get("name") != "@michaelxwb/code-flow":
|
|
642
|
+
return ""
|
|
643
|
+
version = data.get("version")
|
|
644
|
+
return version if isinstance(version, str) else ""
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _valid_version(value: str) -> bool:
|
|
648
|
+
parts = value.split(".")
|
|
649
|
+
return len(parts) == 3 and all(part.isdigit() for part in parts)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _doctor_manifest(root: Path, path: Path, version: str,
|
|
653
|
+
runtime: dict[str, object]) -> list[dict[str, str]]:
|
|
654
|
+
if not path.is_file():
|
|
655
|
+
return [_doctor_finding("runtime_manifest_missing", "block", "缺少 .runtime-install.json",
|
|
656
|
+
str(path), "code-flow migrate --runtime --apply")]
|
|
657
|
+
try:
|
|
658
|
+
manifest = json.loads(path.read_text(encoding="utf-8"))
|
|
659
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
660
|
+
return [_doctor_finding("runtime_manifest_invalid", "block", str(exc), str(path),
|
|
661
|
+
"code-flow migrate --runtime --apply")]
|
|
662
|
+
if not isinstance(manifest, dict) or manifest.get("schema_version") != 1:
|
|
663
|
+
return [_doctor_finding("runtime_manifest_invalid", "block", "manifest schema 不支持",
|
|
664
|
+
str(path), "code-flow migrate --runtime --apply")]
|
|
665
|
+
manifest_version = manifest.get("version")
|
|
666
|
+
runtime["manifest_version"] = manifest_version if isinstance(manifest_version, str) else ""
|
|
667
|
+
files = manifest.get("files")
|
|
668
|
+
if not isinstance(files, dict) or not files:
|
|
669
|
+
return [_doctor_finding("runtime_manifest_incomplete", "block", "manifest.files 为空",
|
|
670
|
+
str(path), "code-flow migrate --runtime --apply")]
|
|
671
|
+
findings: list[dict[str, str]] = []
|
|
672
|
+
if manifest_version != version:
|
|
673
|
+
findings.append(_doctor_finding(
|
|
674
|
+
"runtime_version_drift", "block",
|
|
675
|
+
f"version {version} 与 manifest {manifest_version} 不一致", str(path),
|
|
676
|
+
"code-flow migrate --runtime --apply"))
|
|
677
|
+
missing, drifted = _doctor_files(root, files)
|
|
678
|
+
runtime["files_checked"] = len(files)
|
|
679
|
+
runtime["files_missing"] = missing
|
|
680
|
+
runtime["files_drifted"] = drifted
|
|
681
|
+
if missing:
|
|
682
|
+
findings.append(_doctor_finding(
|
|
683
|
+
"runtime_file_missing", "block", f"{len(missing)} 个受管理文件缺失: {', '.join(missing[:5])}",
|
|
684
|
+
str(root / ".code-flow"), "code-flow migrate --runtime --apply"))
|
|
685
|
+
if drifted:
|
|
686
|
+
findings.append(_doctor_finding(
|
|
687
|
+
"runtime_file_drift", "block", f"{len(drifted)} 个受管理文件被修改: {', '.join(drifted[:5])}",
|
|
688
|
+
str(root / ".code-flow"), "code-flow migrate --runtime --apply"))
|
|
689
|
+
if not isinstance(manifest.get("hook_contracts"), dict):
|
|
690
|
+
findings.append(_doctor_finding("runtime_hooks_missing", "warn", "manifest 缺 hook_contracts",
|
|
691
|
+
str(path), "code-flow migrate --runtime --apply"))
|
|
692
|
+
return findings
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _doctor_files(root: Path, files: Mapping[str, object]) -> tuple[list[str], list[str]]:
|
|
696
|
+
missing: list[str] = []
|
|
697
|
+
drifted: list[str] = []
|
|
698
|
+
base = root.resolve()
|
|
699
|
+
for relative, expected in files.items():
|
|
700
|
+
target = (root / str(relative)).resolve()
|
|
701
|
+
if target != base and base not in target.parents:
|
|
702
|
+
missing.append(str(relative))
|
|
703
|
+
elif not target.is_file():
|
|
704
|
+
missing.append(str(relative))
|
|
705
|
+
elif not isinstance(expected, str) or _file_sha256(target) != expected:
|
|
706
|
+
drifted.append(str(relative))
|
|
707
|
+
return missing, drifted
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _doctor_contract(root: Path, runtime: dict[str, object]) -> list[dict[str, str]]:
|
|
711
|
+
path = root / ".code-flow/runtime-commands.json"
|
|
712
|
+
try:
|
|
713
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
714
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
715
|
+
return [_doctor_finding("runtime_contract_invalid", "block", str(exc), str(path),
|
|
716
|
+
"code-flow migrate --runtime --apply")]
|
|
717
|
+
commands = data.get("commands") if isinstance(data, dict) else None
|
|
718
|
+
if not isinstance(data, dict) or data.get("schema_version") != 1 or not isinstance(commands, dict) or not commands:
|
|
719
|
+
return [_doctor_finding("runtime_contract_invalid", "block", "runtime-commands.json 契约损坏",
|
|
720
|
+
str(path), "code-flow migrate --runtime --apply")]
|
|
721
|
+
runtime["commands"] = len(commands)
|
|
722
|
+
return []
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _doctor_config(root: Path) -> list[dict[str, str]]:
|
|
726
|
+
import yaml
|
|
727
|
+
|
|
728
|
+
path = root / ".code-flow/config.yml"
|
|
729
|
+
if not path.is_file():
|
|
730
|
+
return [_doctor_finding("config_missing", "warn", "缺少 config.yml", str(path), "code-flow init")]
|
|
731
|
+
try:
|
|
732
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
733
|
+
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
734
|
+
return [_doctor_finding("config_unreadable", "block", str(exc), str(path), "修复 config.yml 后重试")]
|
|
735
|
+
if not isinstance(data, dict):
|
|
736
|
+
return [_doctor_finding("config_unreadable", "block", "config.yml 不是 mapping", str(path),
|
|
737
|
+
"修复 config.yml 后重试")]
|
|
738
|
+
workflow = data.get("spec_workflow")
|
|
739
|
+
schema = workflow.get("schema_version") if isinstance(workflow, dict) else None
|
|
740
|
+
if schema is None:
|
|
741
|
+
return [_doctor_finding("config_schema_missing", "warn", "spec_workflow.schema_version 未声明",
|
|
742
|
+
str(path), "code-flow migrate --spec-workflow --dry-run")]
|
|
743
|
+
if schema != 1:
|
|
744
|
+
return [_doctor_finding("config_schema_unsupported", "block", f"不支持的 schema {schema!r}",
|
|
745
|
+
str(path), "code-flow migrate --spec-workflow --dry-run")]
|
|
746
|
+
mode = workflow.get("enforcement")
|
|
747
|
+
if isinstance(mode, str) and mode in {"warn", "inject"}:
|
|
748
|
+
return [_doctor_finding("legacy_enforcement_mode", "warn",
|
|
749
|
+
"历史 warn/inject 模式会降低部分验证门禁;路由完整性错误仍阻断",
|
|
750
|
+
str(path), "审查现有工作流后显式将 spec_workflow.enforcement 改为 required")]
|
|
751
|
+
return []
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _doctor_layout(root: Path) -> list[dict[str, str]]:
|
|
755
|
+
from codeflow.core.cf_core import load_config
|
|
756
|
+
try:
|
|
757
|
+
config = load_config(str(root))
|
|
758
|
+
except (OSError, ValueError) as exc:
|
|
759
|
+
return [_doctor_finding("domain_config_invalid", "block", str(exc),
|
|
760
|
+
str(root / '.code-flow/config.yml'), '修复 config.yml 后重试')]
|
|
761
|
+
workflow = config.get("spec_workflow") or {}
|
|
762
|
+
mapping = config.get("path_mapping") or {}
|
|
763
|
+
if not isinstance(workflow, dict) or not isinstance(mapping, dict):
|
|
764
|
+
return [_doctor_finding("domain_config_invalid", "block", "domain 配置必须是 mapping")]
|
|
765
|
+
strict = workflow.get("strict_domain_layout", False)
|
|
766
|
+
if type(strict) is not bool:
|
|
767
|
+
return [_doctor_finding("domain_config_invalid", "block", "strict_domain_layout 必须是布尔值")]
|
|
768
|
+
severity = "block" if strict else "warn"
|
|
769
|
+
return [finding for domain, entry in sorted(mapping.items())
|
|
770
|
+
for finding in _domain_findings(root, str(domain), entry, severity)]
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _domain_findings(root: Path, domain: str, entry: object, severity: str) -> list[dict[str, str]]:
|
|
774
|
+
specs = root / '.code-flow/specs'
|
|
775
|
+
findings: list[dict[str, str]] = []
|
|
776
|
+
if domain not in ('shared', '_session') and not (specs / domain).is_dir():
|
|
777
|
+
findings.append(_doctor_finding('domain_directory_missing', severity, f'配置域 {domain} 没有目录',
|
|
778
|
+
str(specs / domain), '创建域目录或修正 path_mapping'))
|
|
779
|
+
if not isinstance(entry, dict) or not isinstance(entry.get('specs', []), list):
|
|
780
|
+
return findings
|
|
781
|
+
for item in entry.get('specs', []):
|
|
782
|
+
path = item if isinstance(item, str) else item.get('path', '') if isinstance(item, dict) else ''
|
|
783
|
+
if isinstance(path, str) and path and path.replace('\\', '/').split('/')[0] != domain:
|
|
784
|
+
findings.append(_doctor_finding('domain_cross_reference', severity, f'{domain} 跨域声明 {path}',
|
|
785
|
+
str(specs / path), '将规范放入所属域或修正配置'))
|
|
786
|
+
findings.extend(_domain_auto_discovered(specs, domain, entry))
|
|
787
|
+
return findings
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _domain_auto_discovered(specs: Path, domain: str, entry: dict) -> list[dict[str, str]]:
|
|
791
|
+
"""Describe implicit entries without claiming they are missing from routing."""
|
|
792
|
+
directory = specs / domain
|
|
793
|
+
if domain in ('shared', '_session') or not directory.is_dir():
|
|
794
|
+
return []
|
|
795
|
+
declared = {item if isinstance(item, str) else item.get('path', '')
|
|
796
|
+
for item in entry.get('specs', []) if isinstance(item, (str, dict))}
|
|
797
|
+
findings: list[dict[str, str]] = []
|
|
798
|
+
for path in sorted(directory.rglob('*.md')):
|
|
799
|
+
relative = path.relative_to(specs).as_posix()
|
|
800
|
+
if path.name == '_map.md' or relative in declared:
|
|
801
|
+
continue
|
|
802
|
+
findings.append(_doctor_finding('domain_auto_discovered', 'warn',
|
|
803
|
+
f'{relative} 未显式登记,已由自动发现纳入', str(path),
|
|
804
|
+
'如需固定标签或层级,可在 path_mapping 中显式登记'))
|
|
805
|
+
return findings
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _doctor_migrations(root: Path) -> list[dict[str, str]]:
|
|
809
|
+
findings: list[dict[str, str]] = []
|
|
810
|
+
lock = root / ".code-flow/.runtime-migration.lock"
|
|
811
|
+
if lock.exists():
|
|
812
|
+
findings.append(_doctor_finding("runtime_migration_lock", "block", "存在运行时迁移锁", str(lock),
|
|
813
|
+
"按 lock owner 输出执行 migrate --runtime --rollback <id>"))
|
|
814
|
+
workspace = root / ".code-flow/migrations"
|
|
815
|
+
for journal in sorted(workspace.glob("runtime-*/journal.json")) if workspace.is_dir() else ():
|
|
816
|
+
try:
|
|
817
|
+
status = json.loads(journal.read_text(encoding="utf-8")).get("status")
|
|
818
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
819
|
+
findings.append(_doctor_finding("migration_journal_invalid", "warn", "迁移 journal 不可读",
|
|
820
|
+
str(journal), "检查 migrations 目录"))
|
|
821
|
+
continue
|
|
822
|
+
if status in ("staging", "applying"):
|
|
823
|
+
findings.append(_doctor_finding(
|
|
824
|
+
"runtime_migration_recovery", "block", f"迁移未完成: {status}", str(journal),
|
|
825
|
+
f"code-flow migrate --runtime --rollback {journal.parent.name}"))
|
|
826
|
+
return findings
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _doctor_legacy(root: Path) -> list[dict[str, str]]:
|
|
830
|
+
retired = ("cf_inject_hook.py", "cf_session_hook.py", "cf_codex_user_prompt_hook.py")
|
|
831
|
+
findings = [
|
|
832
|
+
_doctor_finding("legacy_runtime_residue", "warn", f"旧 Hook 脚本仍存在: {name}",
|
|
833
|
+
str(root / ".code-flow/scripts" / name), "code-flow migrate --runtime --apply")
|
|
834
|
+
for name in retired if (root / ".code-flow/scripts" / name).exists()
|
|
835
|
+
]
|
|
836
|
+
legacy_inject = root / ".claude/skills/cf-inject.md"
|
|
837
|
+
if legacy_inject.exists():
|
|
838
|
+
findings.append(_doctor_finding("legacy_skill_residue", "warn", "旧 cf-inject skill 仍存在",
|
|
839
|
+
str(legacy_inject), "code-flow init --force"))
|
|
840
|
+
return findings
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _doctor_marker(root: Path, task_dir: str, context: SpecContext) -> tuple[dict[str, object], list[dict[str, str]]]:
|
|
844
|
+
marker_path = root / ".code-flow/.active-task.json"
|
|
845
|
+
info: dict[str, object] = {"exists": False, "task_id": "", "status": "", "hash_match": False,
|
|
846
|
+
"task_match": False}
|
|
847
|
+
if not marker_path.is_file():
|
|
848
|
+
return info, []
|
|
849
|
+
try:
|
|
850
|
+
active = load_active_task(str(root))
|
|
851
|
+
except ContextError as exc:
|
|
852
|
+
return info, [_doctor_finding("corrupt_marker", "block", exc.message, str(marker_path),
|
|
853
|
+
"确认放弃后运行 code-flow spec active doctor --abandon")]
|
|
854
|
+
task_match = Path(active.task_dir).resolve() == Path(task_dir).resolve()
|
|
855
|
+
hash_match = active.context_sha256 == context_sha256(context)
|
|
856
|
+
info.update({"exists": True, "task_id": active.task_id, "status": active.status,
|
|
857
|
+
"hash_match": hash_match, "task_match": task_match})
|
|
858
|
+
findings: list[dict[str, str]] = []
|
|
859
|
+
if not hash_match:
|
|
860
|
+
findings.append(_doctor_finding(
|
|
861
|
+
"marker_hash_drift", "block", "marker context hash 漂移", str(marker_path),
|
|
862
|
+
"code-flow spec refresh --task-dir <需求目录> --root \"$PWD\" --json"))
|
|
863
|
+
if not task_match:
|
|
864
|
+
findings.append(_doctor_finding(
|
|
865
|
+
"marker_task_mismatch", "block", f"marker 属于 {active.task_dir}", str(marker_path),
|
|
866
|
+
"回到原需求目录,或用 spec active doctor --resync 重新绑定"))
|
|
867
|
+
if active.status == "activating":
|
|
868
|
+
findings.append(_doctor_finding(
|
|
869
|
+
"marker_stuck_activating", "block", "start 事务停在 activating", str(marker_path),
|
|
870
|
+
"code-flow spec active doctor --resync --context-sha256 <hash>"))
|
|
871
|
+
return info, findings
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _doctor_command(args: argparse.Namespace) -> dict[str, object]:
|
|
875
|
+
"""Read-only drift diagnosis for Context, marker and installed runtime."""
|
|
876
|
+
task_dir = Path(args.task_dir)
|
|
877
|
+
context_path = task_dir / "spec-context.yml"
|
|
878
|
+
runtime: dict[str, object] = {"installed": False, "version": "", "manifest_version": "",
|
|
879
|
+
"files_checked": 0, "files_missing": [], "files_drifted": [],
|
|
880
|
+
"commands": 0}
|
|
881
|
+
empty_marker: dict[str, object] = {"exists": False, "task_id": "", "status": "",
|
|
882
|
+
"hash_match": False, "task_match": False}
|
|
883
|
+
try:
|
|
884
|
+
context = load_context(str(context_path))
|
|
885
|
+
sha = context_sha256(context)
|
|
886
|
+
except ContextError as exc:
|
|
887
|
+
finding = _doctor_finding("invalid_context", "block", exc.message, str(context_path),
|
|
888
|
+
"修复 spec-context.yml 后运行 code-flow spec refresh")
|
|
889
|
+
return _doctor_report("", "", empty_marker, runtime, [finding])
|
|
890
|
+
root_value = getattr(args, "root", "") or _find_root(str(task_dir))
|
|
891
|
+
if not root_value:
|
|
892
|
+
finding = _doctor_finding("root_not_found", "block", "无法定位项目根目录", str(task_dir),
|
|
893
|
+
"在项目内运行,或显式传入 --root")
|
|
894
|
+
return _doctor_report(context.task, sha, empty_marker, runtime, [finding])
|
|
895
|
+
root = Path(root_value).resolve()
|
|
896
|
+
marker, findings = _doctor_marker(root, str(task_dir), context)
|
|
897
|
+
runtime, runtime_findings = _doctor_runtime(root)
|
|
898
|
+
findings.extend(runtime_findings)
|
|
899
|
+
findings.extend(_doctor_config(root))
|
|
900
|
+
findings.extend(_doctor_layout(root))
|
|
901
|
+
findings.extend(_doctor_migrations(root))
|
|
902
|
+
findings.extend(_doctor_legacy(root))
|
|
903
|
+
from codeflow.spec.cf_rule_readiness import applied_findings, convention_findings
|
|
904
|
+
from codeflow.spec.cf_stage_gate import result_to_data, validate_stage
|
|
905
|
+
findings.extend(applied_findings(context, str(root), task_dir=str(task_dir)))
|
|
906
|
+
findings.extend(convention_findings(str(root), [binding.path for binding in context.bindings]))
|
|
907
|
+
report = _doctor_report(context.task, sha, marker, runtime, findings)
|
|
908
|
+
report["readiness"] = {stage: result_to_data(validate_stage(context, stage, root=str(root), task_dir=str(task_dir)))
|
|
909
|
+
for stage in ("plan", "code", "review")}
|
|
910
|
+
return report
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _doctor_report(task: str, sha: str, marker: Mapping[str, object],
|
|
914
|
+
runtime: Mapping[str, object], findings: Sequence[Mapping[str, str]]) -> dict[str, object]:
|
|
915
|
+
blocking = [item for item in findings if item["severity"] == "block"]
|
|
916
|
+
next_steps: list[str] = []
|
|
917
|
+
for item in blocking:
|
|
918
|
+
if item["fix"] and item["fix"] not in next_steps:
|
|
919
|
+
next_steps.append(item["fix"])
|
|
920
|
+
return {
|
|
921
|
+
"ok": not blocking,
|
|
922
|
+
"decision": "block" if blocking else "pass",
|
|
923
|
+
"task": task,
|
|
924
|
+
"context_sha256": sha,
|
|
925
|
+
"marker": dict(marker),
|
|
926
|
+
"runtime": dict(runtime),
|
|
927
|
+
"findings": [dict(item) for item in findings],
|
|
928
|
+
"next": next_steps,
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _doctor_text(data: dict[str, object]) -> str:
|
|
933
|
+
lines = [f"# {data['task'] or '<unknown>'} — Spec Doctor",
|
|
934
|
+
f"- decision: {data['decision']}"]
|
|
935
|
+
runtime = data["runtime"]
|
|
936
|
+
lines.append(f"- runtime: installed={runtime['installed']} version={runtime['version'] or '-'} "
|
|
937
|
+
f"files={runtime['files_checked']}")
|
|
938
|
+
findings = data["findings"]
|
|
939
|
+
if not findings:
|
|
940
|
+
lines.append("- 未发现漂移")
|
|
941
|
+
for item in findings:
|
|
942
|
+
lines.append(f"- [{item['severity']}] {item['code']}: {item['message']}")
|
|
943
|
+
if item["fix"]:
|
|
944
|
+
lines.append(f" 修复: {item['fix']}")
|
|
945
|
+
return "\n".join(lines)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _parser() -> argparse.ArgumentParser:
|
|
949
|
+
parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND", "cf_spec_context.py"))
|
|
950
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
951
|
+
catalog = commands.add_parser("catalog")
|
|
952
|
+
catalog.add_argument("--root", required=True)
|
|
953
|
+
catalog.add_argument("--stage", required=True)
|
|
954
|
+
catalog.add_argument("--paths", nargs="*", default=())
|
|
955
|
+
catalog.add_argument("--json", action="store_true")
|
|
956
|
+
budget = commands.add_parser("budget")
|
|
957
|
+
budget.add_argument("--paths", nargs="*", default=())
|
|
958
|
+
budget.add_argument("--changed", action="store_true")
|
|
959
|
+
budget.add_argument("--json", action="store_true")
|
|
960
|
+
impact = commands.add_parser("impact")
|
|
961
|
+
impact.add_argument("spec")
|
|
962
|
+
impact.add_argument("--max-files", type=int, default=5000)
|
|
963
|
+
impact.add_argument("--max-depth", type=int, default=20)
|
|
964
|
+
impact.add_argument("--json", action="store_true")
|
|
965
|
+
optimize = commands.add_parser("optimize")
|
|
966
|
+
optimize_actions = optimize.add_mutually_exclusive_group(required=True)
|
|
967
|
+
optimize_actions.add_argument("--dry-run", action="store_true")
|
|
968
|
+
optimize_actions.add_argument("--apply", action="store_true")
|
|
969
|
+
optimize.add_argument("--paths", nargs="*", default=())
|
|
970
|
+
optimize.add_argument("--target", default="")
|
|
971
|
+
optimize.add_argument("--plan", default="")
|
|
972
|
+
optimize.add_argument("--json", action="store_true")
|
|
973
|
+
decision = commands.add_parser("decision")
|
|
974
|
+
decision.add_argument("--task-dir", required=True)
|
|
975
|
+
decision.add_argument("--json", action="store_true")
|
|
976
|
+
bind = commands.add_parser("bind")
|
|
977
|
+
bind.add_argument("--task-dir", required=True)
|
|
978
|
+
bind.add_argument("--root", required=True)
|
|
979
|
+
bind.add_argument("--stage", required=True)
|
|
980
|
+
bind.add_argument("--json", action="store_true")
|
|
981
|
+
validate = commands.add_parser("validate")
|
|
982
|
+
validate.add_argument("--task-dir", required=True)
|
|
983
|
+
validate.add_argument("--json", action="store_true")
|
|
984
|
+
status = commands.add_parser("status")
|
|
985
|
+
status.add_argument("--task-dir", required=True)
|
|
986
|
+
status.add_argument("--root", default="")
|
|
987
|
+
status.add_argument("--json", action="store_true")
|
|
988
|
+
doctor = commands.add_parser("doctor")
|
|
989
|
+
doctor.add_argument("--task-dir", required=True)
|
|
990
|
+
doctor.add_argument("--root", default="")
|
|
991
|
+
doctor.add_argument("--json", action="store_true")
|
|
992
|
+
for name in ("refresh", "refresh-missing"):
|
|
993
|
+
refresh = commands.add_parser(name)
|
|
994
|
+
refresh.add_argument("--task-dir", required=True)
|
|
995
|
+
refresh.add_argument("--root", required=True)
|
|
996
|
+
refresh.add_argument("--json", action="store_true")
|
|
997
|
+
active = commands.add_parser("active")
|
|
998
|
+
active_actions = active.add_subparsers(dest="active_action", required=True)
|
|
999
|
+
for action in ("start", "pause", "resume", "block", "complete", "doctor"):
|
|
1000
|
+
command = active_actions.add_parser(action)
|
|
1001
|
+
command.add_argument("--root", required=True)
|
|
1002
|
+
command.add_argument("--task-dir", required=True)
|
|
1003
|
+
command.add_argument("--task", required=True)
|
|
1004
|
+
command.add_argument("--context-sha256", required=True)
|
|
1005
|
+
command.add_argument("--json", action="store_true")
|
|
1006
|
+
if action == "doctor":
|
|
1007
|
+
command.add_argument("--abandon", action="store_true", help="确认放弃无法证明的 marker(需用户确认)")
|
|
1008
|
+
command.add_argument("--resync", action="store_true", help="用 --context-sha256 重新绑定 marker 并恢复 active")
|
|
1009
|
+
start = commands.add_parser("start")
|
|
1010
|
+
start.add_argument("--task-dir", required=True)
|
|
1011
|
+
start.add_argument("--root", required=True)
|
|
1012
|
+
start.add_argument("--task", required=True)
|
|
1013
|
+
start.add_argument("--task-file", required=True)
|
|
1014
|
+
start.add_argument("--session-output", default="")
|
|
1015
|
+
start.add_argument("--json", action="store_true")
|
|
1016
|
+
return parser
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _apply_optimization(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
|
|
1020
|
+
from codeflow.spec.cf_spec_optimize_apply import apply_proposal
|
|
1021
|
+
if not args.plan or args.paths or args.target:
|
|
1022
|
+
raise ValueError('--apply requires --plan and does not accept --paths or --target')
|
|
1023
|
+
path = Path(args.plan)
|
|
1024
|
+
if path.stat().st_size > 2_000_000:
|
|
1025
|
+
raise ValueError('proposal file exceeds size limit')
|
|
1026
|
+
proposal = json.loads(path.read_text(encoding='utf-8'))
|
|
1027
|
+
if not isinstance(proposal, dict):
|
|
1028
|
+
raise ValueError('proposal must be a JSON object')
|
|
1029
|
+
if not stdin.isatty():
|
|
1030
|
+
raise ValueError('explicit interactive confirmation requires a terminal')
|
|
1031
|
+
summary = {key: proposal.get(key) for key in ('proposal_id', 'operations', 'mapping_diff', 'budget', 'affected_contexts')}
|
|
1032
|
+
sys.stderr.write(json.dumps(summary, ensure_ascii=False) + '\n确认应用上述提案?输入 y 确认: ')
|
|
1033
|
+
sys.stderr.flush()
|
|
1034
|
+
if stdin.readline().strip().lower() != 'y':
|
|
1035
|
+
raise ValueError('proposal was not confirmed')
|
|
1036
|
+
return apply_proposal(os.getcwd(), proposal, confirmed=True)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
|
|
1040
|
+
if args.command == "optimize":
|
|
1041
|
+
from codeflow.spec.cf_spec_optimize import build_proposal
|
|
1042
|
+
try:
|
|
1043
|
+
if args.apply:
|
|
1044
|
+
return _apply_optimization(args, stdin)
|
|
1045
|
+
if args.plan:
|
|
1046
|
+
raise ValueError('--plan is only supported with --apply')
|
|
1047
|
+
return build_proposal(os.getcwd(), tuple(args.paths), args.target)
|
|
1048
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
1049
|
+
return {"ok": False, "error": {"code": "proposal_unavailable", "message": str(exc)}}
|
|
1050
|
+
if args.command == "impact":
|
|
1051
|
+
from codeflow.spec.cf_spec_impact import inspect_impact
|
|
1052
|
+
try:
|
|
1053
|
+
return inspect_impact(os.getcwd(), args.spec, args.max_files, args.max_depth)
|
|
1054
|
+
except ValueError as exc:
|
|
1055
|
+
message = str(exc)
|
|
1056
|
+
code = message if message in ("spec_not_found", "spec_identity_ambiguous") else "impact_invalid"
|
|
1057
|
+
return {"ok": False, "error": {"code": code, "message": message}}
|
|
1058
|
+
if args.command == "budget":
|
|
1059
|
+
from codeflow.core.cf_git import GitError, changed_files
|
|
1060
|
+
from codeflow.spec.cf_spec_budget import measure_path_budget
|
|
1061
|
+
paths = list(args.paths)
|
|
1062
|
+
if args.changed:
|
|
1063
|
+
try:
|
|
1064
|
+
paths.extend(changed_files(os.getcwd()))
|
|
1065
|
+
except GitError as exc:
|
|
1066
|
+
return {"ok": False, "error": {"code": "git_error", "message": str(exc)}}
|
|
1067
|
+
if not paths and not args.changed:
|
|
1068
|
+
return {"ok": False, "error": {"code": "paths_required", "message": "spec budget requires --paths or --changed"}}
|
|
1069
|
+
try:
|
|
1070
|
+
return measure_path_budget(os.getcwd(), tuple(dict.fromkeys(paths)))
|
|
1071
|
+
except (TypeError, ValueError) as exc:
|
|
1072
|
+
return {"ok": False, "error": {"code": getattr(exc, "code", "invalid_config"), "message": str(exc)}}
|
|
1073
|
+
if args.command == "catalog":
|
|
1074
|
+
return _catalog_command(args)
|
|
1075
|
+
if args.command == "doctor":
|
|
1076
|
+
return _doctor_command(args)
|
|
1077
|
+
if args.command == "active":
|
|
1078
|
+
return _active_command(args, _json_payload(stdin))
|
|
1079
|
+
if args.command == "start":
|
|
1080
|
+
return _start_command(args, _json_payload(stdin))
|
|
1081
|
+
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
1082
|
+
if args.command == "decision":
|
|
1083
|
+
return _decision_command(args, _json_payload(stdin))
|
|
1084
|
+
if args.command == "bind":
|
|
1085
|
+
return _bind_command(args, _json_payload(stdin))
|
|
1086
|
+
if args.command == "validate":
|
|
1087
|
+
context = load_context(str(context_path))
|
|
1088
|
+
return {"ok": True, "task": context.task, "bindings": len(context.bindings)}
|
|
1089
|
+
if args.command == "refresh-missing":
|
|
1090
|
+
context = refresh_missing_specs(load_context(str(context_path)), args.root)
|
|
1091
|
+
changes: tuple[DriftChange, ...] = ()
|
|
1092
|
+
else:
|
|
1093
|
+
result = refresh_context(load_context(str(context_path)), args.root, artifact_root=args.task_dir)
|
|
1094
|
+
context, changes = result.context, result.changes
|
|
1095
|
+
save_context(str(context_path), context)
|
|
1096
|
+
_resync_after_save(args, context_path)
|
|
1097
|
+
return {
|
|
1098
|
+
"ok": True,
|
|
1099
|
+
"missing": sum(item.status == "missing" for item in context.bindings),
|
|
1100
|
+
"changes": [change.__dict__ for change in changes],
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def main(argv: Optional[Sequence[str]] = None, stdin: IO[str] = sys.stdin, stdout: IO[str] = sys.stdout) -> int:
|
|
1105
|
+
try:
|
|
1106
|
+
args = _parser().parse_args(argv)
|
|
1107
|
+
if args.command == "status":
|
|
1108
|
+
data = _status_command(args)
|
|
1109
|
+
if args.json:
|
|
1110
|
+
stdout.write(json.dumps(data, ensure_ascii=False))
|
|
1111
|
+
else:
|
|
1112
|
+
stdout.write(_status_text(data) + "\n")
|
|
1113
|
+
return 0
|
|
1114
|
+
if args.command == "doctor":
|
|
1115
|
+
data = _doctor_command(args)
|
|
1116
|
+
if args.json:
|
|
1117
|
+
stdout.write(json.dumps(data, ensure_ascii=False))
|
|
1118
|
+
else:
|
|
1119
|
+
stdout.write(_doctor_text(data) + "\n")
|
|
1120
|
+
return 0 if data["decision"] == "pass" else 3
|
|
1121
|
+
result = _execute(args, stdin)
|
|
1122
|
+
if args.command == "catalog" and not args.json and result.get("ok"):
|
|
1123
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
1124
|
+
if result.get("dropped_count"):
|
|
1125
|
+
stdout.write(f"\n因预算未列出 {result['dropped_count']} 项,涉及域: {', '.join(result['dropped_domains'])}\n")
|
|
1126
|
+
return 0
|
|
1127
|
+
if args.command == "impact" and not args.json:
|
|
1128
|
+
if result.get("ok") is False:
|
|
1129
|
+
stdout.write(f"影响查询失败: {result['error']['message']}\n")
|
|
1130
|
+
return 3
|
|
1131
|
+
stdout.write(f"规范: {result['spec_id']} ({result['spec_path']})\n")
|
|
1132
|
+
for item in result['references']:
|
|
1133
|
+
stdout.write(f" - {item['source']}: {item.get('task_path', item.get('context_path', ''))} "
|
|
1134
|
+
f"{item.get('task_id', '')} {item.get('rule_ref', '')}\n")
|
|
1135
|
+
for item in result['errors']:
|
|
1136
|
+
stdout.write(f" - 读取失败: {item['path']}: {item['message']}\n")
|
|
1137
|
+
stdout.write(f"已扫描: {result['scanned_files']},截断: {bool(result['truncated'])}\n")
|
|
1138
|
+
stdout.write(f"{result['next']}\n")
|
|
1139
|
+
return 3 if result['truncated'] or result['errors'] else 0
|
|
1140
|
+
if args.command == "optimize" and not args.json:
|
|
1141
|
+
if result.get("ok") is False:
|
|
1142
|
+
stdout.write(f"优化预览失败: {result['error']['message']}\n")
|
|
1143
|
+
return 3
|
|
1144
|
+
if args.apply:
|
|
1145
|
+
stdout.write(f"已应用提案 {result['proposal_id']}: {', '.join(result['changed'])}\n")
|
|
1146
|
+
return 0
|
|
1147
|
+
stdout.write(f"提案: {result['proposal_id']},规范: {len(result['specs'])},"
|
|
1148
|
+
f"建议: {len(result['suggestions'])}\n{result['note']}\n")
|
|
1149
|
+
return 0
|
|
1150
|
+
if args.command == "budget" and not args.json:
|
|
1151
|
+
if result.get("ok") is False:
|
|
1152
|
+
stdout.write(f"预算检查失败: {result['error']['message']}\n")
|
|
1153
|
+
return 3
|
|
1154
|
+
stdout.write(f"单次注入: {result['matched_total']} / {result['limit']} tokens\n"
|
|
1155
|
+
f"规范全量聚合: {result['aggregate_total']} tokens\n"
|
|
1156
|
+
f"超限: {'是' if result['over_limit'] else '否'}\n")
|
|
1157
|
+
for item in result['matched_specs']:
|
|
1158
|
+
stdout.write(f" - {item['path']}: {item['tokens']} tokens\n")
|
|
1159
|
+
return 3 if result['over_limit'] else 0
|
|
1160
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
1161
|
+
return 3 if ((args.command == "budget" and result.get("over_limit"))
|
|
1162
|
+
or (args.command == "impact" and (result.get("truncated") or result.get("errors")))
|
|
1163
|
+
or result.get("ok") is False) else 0
|
|
1164
|
+
except ContextError as exc:
|
|
1165
|
+
stdout.write(json.dumps({"ok": False, "error": exc.to_dict()}, ensure_ascii=False))
|
|
1166
|
+
return 3
|
|
1167
|
+
except Exception as exc:
|
|
1168
|
+
payload = error_payload(exc)
|
|
1169
|
+
if payload is not None:
|
|
1170
|
+
# Same error contract, different module instance (direct script run).
|
|
1171
|
+
stdout.write(json.dumps({"ok": False, "error": payload}, ensure_ascii=False))
|
|
1172
|
+
return 3
|
|
1173
|
+
sys.stderr.write(f"cf_spec_context unexpected error: {exc}\n")
|
|
1174
|
+
stdout.write(json.dumps({"ok": False, "error": {"code": "internal_error"}}, ensure_ascii=False))
|
|
1175
|
+
return 2
|