@michaelxwb/code-flow 0.5.1 → 0.6.0
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/docs/migrations/spec-workflow-0.6.0.md +42 -0
- package/package.json +4 -2
- package/src/adapters/claude/CLAUDE.md +11 -17
- package/src/adapters/claude/commands/cf-init.md +5 -4
- package/src/adapters/claude/commands/cf-spec.md +50 -0
- package/src/adapters/claude/commands/cf-task/align.md +29 -3
- package/src/adapters/claude/commands/cf-task/archive.md +32 -4
- package/src/adapters/claude/commands/cf-task/plan.md +54 -1
- package/src/adapters/claude/commands/cf-task/prd.md +24 -0
- package/src/adapters/claude/commands/cf-task/start.md +56 -14
- package/src/adapters/claude/settings.local.json +1 -11
- package/src/adapters/codex/AGENTS.md +11 -17
- package/src/adapters/codex/hooks.json +6 -4
- package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +55 -0
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +29 -3
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +32 -4
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +54 -1
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +56 -14
- package/src/adapters/costrict/CLAUDE.md +11 -17
- package/src/adapters/costrict/commands/cf-init.md +5 -4
- package/src/adapters/costrict/commands/cf-spec.md +50 -0
- package/src/adapters/costrict/commands/cf-task/align.md +29 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +32 -4
- package/src/adapters/costrict/commands/cf-task/plan.md +54 -1
- package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
- package/src/adapters/costrict/commands/cf-task/start.md +56 -14
- package/src/adapters/costrict/settings.local.json +1 -11
- package/src/adapters/opencode/AGENTS.md +11 -17
- package/src/adapters/opencode/commands/cf-init.md +4 -3
- package/src/adapters/opencode/commands/cf-spec.md +54 -0
- package/src/adapters/opencode/commands/cf-task/align.md +30 -3
- package/src/adapters/opencode/commands/cf-task/archive.md +33 -4
- package/src/adapters/opencode/commands/cf-task/plan.md +55 -1
- package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
- package/src/adapters/opencode/commands/cf-task/start.md +57 -14
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -1
- package/src/cli.js +51 -2
- package/src/core/code-flow/config.yml +13 -16
- package/src/core/code-flow/scripts/cf_checks.py +30 -0
- package/src/core/code-flow/scripts/cf_core.py +12 -255
- package/src/core/code-flow/scripts/cf_log.py +8 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +33 -9
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +65 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +1233 -0
- package/src/core/code-flow/scripts/cf_spec_gate.py +224 -0
- package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
- package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
- package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
- package/src/core/code-flow/scripts/cf_spec_router.py +145 -0
- package/src/core/code-flow/scripts/cf_spec_session.py +122 -0
- package/src/core/code-flow/scripts/cf_spec_verify.py +293 -0
- package/src/core/code-flow/scripts/cf_stats.py +40 -5
- package/src/core/code-flow/scripts/cf_stop_hook.py +107 -7
- package/src/core/code-flow/scripts/cf_task_runtime.py +142 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +34 -360
- package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
- package/src/core/code-flow/specs/backend/database.md +13 -1
- package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/backend/logging.md +13 -1
- package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
- package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
- package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
- package/src/core/code-flow/specs/shared/design/design-frontend.md +20 -10
- package/src/core/code-flow/specs/shared/design/design-full.md +36 -24
- package/src/core/code-flow/specs/shared/design/design-lite.md +21 -9
- package/src/core/code-flow/specs/shared/prd-template.md +14 -3
- package/src/migrate/spec-workflow.js +238 -0
- package/src/adapters/claude/commands/cf-inject.md +0 -55
- package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
- package/src/adapters/costrict/commands/cf-inject.md +0 -55
- package/src/adapters/opencode/commands/cf-inject.md +0 -59
- package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
- package/src/core/code-flow/scripts/cf_session_hook.py +0 -42
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only migration preflight and staging transformer."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from typing import IO, Mapping, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from cf_spec_context import BindingInput, bind_specs, new_context, save_context
|
|
18
|
+
from cf_spec_metadata import SpecMetadata, SpecMetadataError, load_spec_metadata
|
|
19
|
+
from cf_spec_resolver import SCOPE_PRIORITY, SpecCandidate
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
TARGET_VERSION = "0.6.0"
|
|
23
|
+
TARGET_SCHEMA = 1
|
|
24
|
+
PACKAGE_ROOT = Path(__file__).resolve().parents[4]
|
|
25
|
+
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
26
|
+
_SUFFIXES = (".frontend.design.md", ".backend.design.md", ".design.md", ".prd.md", ".md")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sha256(path: Path) -> str:
|
|
30
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _relative(root: Path, path: Path) -> str:
|
|
34
|
+
return path.relative_to(root).as_posix()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _issue(code: str, path: str, message: str) -> dict[str, str]:
|
|
38
|
+
return {"code": code, "path": path, "message": message}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _version_tuple(value: str) -> Optional[tuple[int, int, int]]:
|
|
42
|
+
match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", value.strip())
|
|
43
|
+
return tuple(int(item) for item in match.groups()) if match else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _source_version(flow: Path) -> str:
|
|
47
|
+
path = flow / ".version"
|
|
48
|
+
try:
|
|
49
|
+
return path.read_text(encoding="utf-8").strip() if path.is_file() else "missing"
|
|
50
|
+
except OSError:
|
|
51
|
+
return "unreadable"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _schema(flow: Path) -> Optional[int]:
|
|
55
|
+
path = flow / "config.yml"
|
|
56
|
+
try:
|
|
57
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
58
|
+
except (OSError, yaml.YAMLError):
|
|
59
|
+
return None
|
|
60
|
+
workflow = data.get("spec_workflow") if isinstance(data, dict) else None
|
|
61
|
+
value = workflow.get("schema_version") if isinstance(workflow, dict) else 0
|
|
62
|
+
return value if isinstance(value, int) else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _version_issues(version: str, schema: Optional[int]) -> list[dict[str, str]]:
|
|
66
|
+
parsed = _version_tuple(version)
|
|
67
|
+
if version in ("missing", "unreadable"):
|
|
68
|
+
return []
|
|
69
|
+
if parsed is None:
|
|
70
|
+
return [_issue("invalid_version", ".code-flow/.version", version)]
|
|
71
|
+
if parsed < (0, 4, 2):
|
|
72
|
+
return [_issue("unsupported_version", ".code-flow/.version", version)]
|
|
73
|
+
if parsed > (0, 6, 0) or (schema is not None and schema > TARGET_SCHEMA):
|
|
74
|
+
return [_issue("future_version", ".code-flow/.version", version)]
|
|
75
|
+
if parsed == (0, 6, 0) and schema != TARGET_SCHEMA:
|
|
76
|
+
return [_issue("incomplete_target_schema", ".code-flow/config.yml", str(schema))]
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _scan_specs(root: Path, flow: Path) -> tuple[list[dict[str, object]], list[dict[str, str]]]:
|
|
81
|
+
manifest: list[dict[str, object]] = []
|
|
82
|
+
unresolved: list[dict[str, str]] = []
|
|
83
|
+
seen: dict[str, str] = {}
|
|
84
|
+
for path in sorted((flow / "specs").rglob("*.md")):
|
|
85
|
+
relative = _relative(root, path)
|
|
86
|
+
spec_relative = path.relative_to(flow / "specs").as_posix()
|
|
87
|
+
if "/_session/" in f"/{relative}/" or path.name == "_map.md" or spec_relative.startswith("shared/"):
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
metadata = load_spec_metadata(str(path))
|
|
91
|
+
except SpecMetadataError as exc:
|
|
92
|
+
unresolved.append(_issue("invalid_spec", relative, exc.message))
|
|
93
|
+
continue
|
|
94
|
+
if metadata.id in seen:
|
|
95
|
+
unresolved.append(_issue("duplicate_spec_id", relative, metadata.id))
|
|
96
|
+
seen[metadata.id] = relative
|
|
97
|
+
manifest.append({"id": metadata.id, "path": relative, "sha256": metadata.hashes.file_sha256})
|
|
98
|
+
return manifest, unresolved
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _demand_key(name: str) -> str:
|
|
102
|
+
for suffix in _SUFFIXES:
|
|
103
|
+
if name.endswith(suffix):
|
|
104
|
+
return name[:-len(suffix)]
|
|
105
|
+
return name
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _target_conflict(files: Sequence[Path], target: Path) -> bool:
|
|
109
|
+
if not target.exists():
|
|
110
|
+
return False
|
|
111
|
+
for source in files:
|
|
112
|
+
destination = target / source.name
|
|
113
|
+
if destination.exists() and destination.read_bytes() != source.read_bytes():
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _flat_transforms(root: Path, date_dir: Path) -> tuple[list[dict[str, object]], list[dict[str, str]]]:
|
|
119
|
+
groups: dict[str, list[Path]] = {}
|
|
120
|
+
for path in sorted(date_dir.glob("*.md")):
|
|
121
|
+
groups.setdefault(_demand_key(path.name), []).append(path)
|
|
122
|
+
transforms: list[dict[str, object]] = []
|
|
123
|
+
unresolved: list[dict[str, str]] = []
|
|
124
|
+
for demand, files in sorted(groups.items()):
|
|
125
|
+
target = date_dir / demand
|
|
126
|
+
if _target_conflict(files, target):
|
|
127
|
+
unresolved.append(_issue("target_conflict", _relative(root, target), demand))
|
|
128
|
+
transforms.append({
|
|
129
|
+
"demand": demand,
|
|
130
|
+
"files": [_relative(root, path) for path in files],
|
|
131
|
+
"target": _relative(root, target),
|
|
132
|
+
})
|
|
133
|
+
return transforms, unresolved
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _scan_tasks(root: Path, flow: Path) -> tuple[list[dict[str, object]], list[dict[str, str]], list[dict[str, object]]]:
|
|
137
|
+
tasks = flow / "tasks"
|
|
138
|
+
transforms: list[dict[str, object]] = []
|
|
139
|
+
unresolved: list[dict[str, str]] = []
|
|
140
|
+
active: list[dict[str, object]] = []
|
|
141
|
+
if not tasks.is_dir():
|
|
142
|
+
return transforms, unresolved, active
|
|
143
|
+
for date_dir in sorted(path for path in tasks.iterdir() if path.is_dir() and _DATE_RE.fullmatch(path.name)):
|
|
144
|
+
found, issues = _flat_transforms(root, date_dir)
|
|
145
|
+
transforms.extend(found)
|
|
146
|
+
unresolved.extend(issues)
|
|
147
|
+
for demand_dir in sorted(path for path in date_dir.iterdir() if path.is_dir()):
|
|
148
|
+
mains = [path for path in demand_dir.glob("*.md") if not path.name.endswith((".prd.md", ".design.md"))]
|
|
149
|
+
if len(mains) > 1:
|
|
150
|
+
unresolved.append(_issue("multiple_task_files", _relative(root, demand_dir), str(len(mains))))
|
|
151
|
+
active.append({"path": _relative(root, demand_dir), "active": True})
|
|
152
|
+
return transforms, unresolved, active
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _archived(root: Path, flow: Path) -> list[dict[str, object]]:
|
|
156
|
+
path = flow / "tasks" / "archived"
|
|
157
|
+
if not path.is_dir():
|
|
158
|
+
return []
|
|
159
|
+
return [
|
|
160
|
+
{"path": _relative(root, item), "sha256": _sha256(item), "active": False}
|
|
161
|
+
for item in sorted(path.rglob("*")) if item.is_file()
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def preflight(root: str) -> dict[str, object]:
|
|
166
|
+
project = Path(root).resolve()
|
|
167
|
+
flow = project / ".code-flow"
|
|
168
|
+
version, schema = _source_version(flow), _schema(flow)
|
|
169
|
+
specs, spec_issues = _scan_specs(project, flow)
|
|
170
|
+
transforms, task_issues, active = _scan_tasks(project, flow)
|
|
171
|
+
unresolved = [*_version_issues(version, schema), *spec_issues, *task_issues]
|
|
172
|
+
warnings = [] if version != "missing" else [_issue("version_mismatch", ".code-flow/.version", "missing")]
|
|
173
|
+
return {
|
|
174
|
+
"project_root": str(project),
|
|
175
|
+
"source_package_version": version,
|
|
176
|
+
"source_schema": schema,
|
|
177
|
+
"target_package_version": TARGET_VERSION,
|
|
178
|
+
"target_schema": TARGET_SCHEMA,
|
|
179
|
+
"specs": specs,
|
|
180
|
+
"layout_transforms": transforms,
|
|
181
|
+
"active_demands": active,
|
|
182
|
+
"archived": _archived(project, flow),
|
|
183
|
+
"unresolved": unresolved,
|
|
184
|
+
"warnings": warnings,
|
|
185
|
+
"ready": not unresolved,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _copy_source(project: Path, staging: Path) -> None:
|
|
190
|
+
source = project / ".code-flow"
|
|
191
|
+
target = staging / ".code-flow"
|
|
192
|
+
shutil.copytree(
|
|
193
|
+
source,
|
|
194
|
+
target,
|
|
195
|
+
dirs_exist_ok=True,
|
|
196
|
+
ignore=shutil.ignore_patterns("migrations", "_session", ".active-task.json", ".active-task.lock"),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _copy_runtime(staging: Path) -> None:
|
|
201
|
+
source = PACKAGE_ROOT / "src/core/code-flow"
|
|
202
|
+
target = staging / ".code-flow"
|
|
203
|
+
shutil.copytree(source / "scripts", target / "scripts", dirs_exist_ok=True)
|
|
204
|
+
shutil.copytree(source / "specs/shared", target / "specs/shared", dirs_exist_ok=True)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _copy_adapter_tree(source: Path, target: Path) -> None:
|
|
208
|
+
if source.is_dir():
|
|
209
|
+
shutil.copytree(source, target, dirs_exist_ok=True)
|
|
210
|
+
elif source.is_file():
|
|
211
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
shutil.copy2(source, target)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _clean_hook_config(data: dict[str, object]) -> None:
|
|
216
|
+
hooks = data.get("hooks")
|
|
217
|
+
if not isinstance(hooks, dict):
|
|
218
|
+
return
|
|
219
|
+
for event, groups in list(hooks.items()):
|
|
220
|
+
if not isinstance(groups, list):
|
|
221
|
+
continue
|
|
222
|
+
for group in groups:
|
|
223
|
+
commands = group.get("hooks") if isinstance(group, dict) else None
|
|
224
|
+
if isinstance(commands, list):
|
|
225
|
+
group["hooks"] = [
|
|
226
|
+
item for item in commands
|
|
227
|
+
if isinstance(item, dict) and not any(token in str(item.get("command", "")) for token in ("cf_inject_hook.py", "cf_session_hook.py"))
|
|
228
|
+
]
|
|
229
|
+
hooks[event] = [group for group in groups if isinstance(group, dict) and group.get("hooks")]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _merge_hook_config(current: dict[str, object], canonical: Mapping[str, object]) -> dict[str, object]:
|
|
233
|
+
_clean_hook_config(current)
|
|
234
|
+
target_hooks = current.setdefault("hooks", {})
|
|
235
|
+
source_hooks = canonical.get("hooks")
|
|
236
|
+
if not isinstance(target_hooks, dict) or not isinstance(source_hooks, dict):
|
|
237
|
+
raise ValueError("invalid hook config")
|
|
238
|
+
for event, source_groups in source_hooks.items():
|
|
239
|
+
target_groups = target_hooks.setdefault(event, [])
|
|
240
|
+
if not isinstance(source_groups, list) or not isinstance(target_groups, list):
|
|
241
|
+
raise ValueError(f"invalid hook event: {event}")
|
|
242
|
+
for source_group in source_groups:
|
|
243
|
+
matcher = source_group.get("matcher", "") if isinstance(source_group, dict) else ""
|
|
244
|
+
target = next((item for item in target_groups if isinstance(item, dict) and item.get("matcher", "") == matcher), None)
|
|
245
|
+
if target is None:
|
|
246
|
+
target_groups.append(source_group)
|
|
247
|
+
continue
|
|
248
|
+
known = {item.get("command") for item in target.get("hooks", []) if isinstance(item, dict)}
|
|
249
|
+
target.setdefault("hooks", []).extend(item for item in source_group.get("hooks", []) if item.get("command") not in known)
|
|
250
|
+
return current
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _write_hook_config(project: Path, canonical: Path, relative: str, staging: Path) -> None:
|
|
254
|
+
source = project / relative
|
|
255
|
+
current = json.loads(source.read_text(encoding="utf-8")) if source.is_file() else {}
|
|
256
|
+
template = json.loads(canonical.read_text(encoding="utf-8"))
|
|
257
|
+
merged = _merge_hook_config(current, template)
|
|
258
|
+
target = staging / relative
|
|
259
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
target.write_text(json.dumps(merged, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _write_adapters(project: Path, staging: Path) -> None:
|
|
264
|
+
adapters = PACKAGE_ROOT / "src/adapters"
|
|
265
|
+
mappings = (
|
|
266
|
+
(adapters / "claude/commands", staging / ".claude/commands"),
|
|
267
|
+
(adapters / "costrict/commands", staging / ".costrict/commands"),
|
|
268
|
+
(adapters / "codex/skills", staging / ".agents/skills"),
|
|
269
|
+
(adapters / "opencode/commands", staging / ".opencode/commands"),
|
|
270
|
+
(adapters / "opencode/plugins", staging / ".opencode/plugins"),
|
|
271
|
+
)
|
|
272
|
+
for source, target in mappings:
|
|
273
|
+
_copy_adapter_tree(source, target)
|
|
274
|
+
for canonical, relative in (
|
|
275
|
+
(adapters / "claude/settings.local.json", ".claude/settings.local.json"),
|
|
276
|
+
(adapters / "costrict/settings.local.json", ".costrict/settings.local.json"),
|
|
277
|
+
(adapters / "codex/hooks.json", ".codex/hooks.json"),
|
|
278
|
+
):
|
|
279
|
+
_write_hook_config(project, canonical, relative, staging)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _managed_block(template: str) -> str:
|
|
283
|
+
match = re.search(
|
|
284
|
+
r"(?s)<!-- code-flow:spec-loading schema=1 start -->.*?<!-- code-flow:spec-loading schema=1 end -->",
|
|
285
|
+
template,
|
|
286
|
+
)
|
|
287
|
+
if match is None:
|
|
288
|
+
raise ValueError("canonical managed block missing")
|
|
289
|
+
return match.group(0)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _replace_managed(text: str, block: str) -> str:
|
|
293
|
+
current = re.search(
|
|
294
|
+
r"(?s)<!-- code-flow:spec-loading schema=\d+ start -->.*?<!-- code-flow:spec-loading schema=\d+ end -->",
|
|
295
|
+
text,
|
|
296
|
+
)
|
|
297
|
+
if current is not None:
|
|
298
|
+
return text[:current.start()] + block + text[current.end():]
|
|
299
|
+
legacy = re.search(r"(?ms)^## Spec Loading\s*$.*?(?=^## |\Z)", text)
|
|
300
|
+
if legacy is not None:
|
|
301
|
+
return text[:legacy.start()] + block + "\n\n" + text[legacy.end():]
|
|
302
|
+
return text.rstrip() + "\n\n" + block + "\n"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _write_agent_files(project: Path, staging: Path) -> None:
|
|
306
|
+
templates = {
|
|
307
|
+
"AGENTS.md": PACKAGE_ROOT / "src/adapters/codex/AGENTS.md",
|
|
308
|
+
"CLAUDE.md": PACKAGE_ROOT / "src/adapters/claude/CLAUDE.md",
|
|
309
|
+
}
|
|
310
|
+
for name, template_path in templates.items():
|
|
311
|
+
source = project / name
|
|
312
|
+
if not source.is_file():
|
|
313
|
+
continue
|
|
314
|
+
block = _managed_block(template_path.read_text(encoding="utf-8"))
|
|
315
|
+
(staging / name).write_text(
|
|
316
|
+
_replace_managed(source.read_text(encoding="utf-8"), block), encoding="utf-8"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _apply_layout(staging: Path, transforms: Sequence[Mapping[str, object]]) -> None:
|
|
321
|
+
for transform in transforms:
|
|
322
|
+
target_value = transform.get("target")
|
|
323
|
+
files_value = transform.get("files")
|
|
324
|
+
if not isinstance(target_value, str) or not isinstance(files_value, list):
|
|
325
|
+
raise ValueError("invalid layout transform")
|
|
326
|
+
target = staging / target_value
|
|
327
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
for relative in files_value:
|
|
329
|
+
if not isinstance(relative, str):
|
|
330
|
+
raise ValueError("invalid layout file")
|
|
331
|
+
source = staging / relative
|
|
332
|
+
shutil.move(str(source), str(target / source.name))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _demand_paths(staging: Path, plan: Mapping[str, object]) -> tuple[Path, ...]:
|
|
336
|
+
paths: set[Path] = set()
|
|
337
|
+
for item in plan.get("layout_transforms", []):
|
|
338
|
+
if isinstance(item, dict) and isinstance(item.get("target"), str):
|
|
339
|
+
paths.add(staging / str(item["target"]))
|
|
340
|
+
for item in plan.get("active_demands", []):
|
|
341
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str):
|
|
342
|
+
paths.add(staging / str(item["path"]))
|
|
343
|
+
return tuple(sorted(paths))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _staged_metadata(staging: Path) -> tuple[dict[str, SpecMetadata], dict[str, SpecMetadata], dict[str, str]]:
|
|
347
|
+
by_id: dict[str, SpecMetadata] = {}
|
|
348
|
+
by_path: dict[str, SpecMetadata] = {}
|
|
349
|
+
paths_by_id: dict[str, str] = {}
|
|
350
|
+
root = staging / ".code-flow/specs"
|
|
351
|
+
for path in root.rglob("*.md"):
|
|
352
|
+
relative = path.relative_to(root).as_posix()
|
|
353
|
+
if path.name == "_map.md" or relative.startswith(("shared/", "_session/")):
|
|
354
|
+
continue
|
|
355
|
+
metadata = load_spec_metadata(str(path))
|
|
356
|
+
by_id[metadata.id] = metadata
|
|
357
|
+
by_path[relative] = metadata
|
|
358
|
+
paths_by_id[metadata.id] = relative
|
|
359
|
+
return by_id, by_path, paths_by_id
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _normalized_refs(value: str, by_id: Mapping[str, SpecMetadata], by_path: Mapping[str, SpecMetadata]) -> tuple[str, ...]:
|
|
363
|
+
refs: list[str] = []
|
|
364
|
+
for raw in value.split(","):
|
|
365
|
+
token = raw.strip().strip("`")
|
|
366
|
+
if "#" in token and token.split("#", 1)[0] in by_id:
|
|
367
|
+
refs.append(token)
|
|
368
|
+
continue
|
|
369
|
+
metadata = by_path.get(token)
|
|
370
|
+
if metadata is not None:
|
|
371
|
+
refs.extend(f"{metadata.id}#{rule.ref}" for rule in metadata.rules if rule.enforcement == "required")
|
|
372
|
+
return tuple(refs)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _rewrite_task_refs(demand: Path, by_id: Mapping[str, SpecMetadata], by_path: Mapping[str, SpecMetadata]) -> tuple[str, ...]:
|
|
376
|
+
found: set[str] = set()
|
|
377
|
+
pattern = re.compile(r"(?m)^- \*\*Spec-Refs\*\*:\s*(.+)$")
|
|
378
|
+
for path in demand.glob("*.md"):
|
|
379
|
+
text = path.read_text(encoding="utf-8")
|
|
380
|
+
def replace(match: re.Match[str]) -> str:
|
|
381
|
+
refs = _normalized_refs(match.group(1), by_id, by_path)
|
|
382
|
+
found.update(refs)
|
|
383
|
+
return "- **Spec-Refs**: " + ", ".join(refs) if refs else match.group(0)
|
|
384
|
+
updated = pattern.sub(replace, text)
|
|
385
|
+
if updated != text:
|
|
386
|
+
path.write_text(updated, encoding="utf-8")
|
|
387
|
+
return tuple(sorted(found))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _write_contexts(staging: Path, plan: Mapping[str, object]) -> None:
|
|
391
|
+
by_id, by_path, paths_by_id = _staged_metadata(staging)
|
|
392
|
+
for demand in _demand_paths(staging, plan):
|
|
393
|
+
demand.mkdir(parents=True, exist_ok=True)
|
|
394
|
+
refs = _rewrite_task_refs(demand, by_id, by_path)
|
|
395
|
+
spec_ids = tuple(sorted({item.split("#", 1)[0] for item in refs}))
|
|
396
|
+
candidates = tuple(
|
|
397
|
+
SpecCandidate(spec_id, paths_by_id[spec_id], "task", SCOPE_PRIORITY["task"], (), by_id[spec_id])
|
|
398
|
+
for spec_id in spec_ids
|
|
399
|
+
)
|
|
400
|
+
context = new_context(demand.name, (("migration", "prepared-plan"),))
|
|
401
|
+
selections = tuple(BindingInput(item, "migration", "legacy Spec-Refs") for item in candidates)
|
|
402
|
+
save_context(str(demand / "spec-context.yml"), bind_specs(context, selections))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _write_target_config(staging: Path) -> None:
|
|
406
|
+
path = staging / ".code-flow/config.yml"
|
|
407
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
408
|
+
if not isinstance(data, dict):
|
|
409
|
+
raise ValueError("invalid config")
|
|
410
|
+
inject = data.get("inject")
|
|
411
|
+
quality = data.get("quality_loop")
|
|
412
|
+
if not isinstance(quality, dict):
|
|
413
|
+
quality = {}
|
|
414
|
+
if isinstance(inject, dict):
|
|
415
|
+
for key in ("compress", "code_extensions", "skip_extensions", "skip_paths"):
|
|
416
|
+
if key in inject and key not in quality:
|
|
417
|
+
quality[key] = inject[key]
|
|
418
|
+
quality.pop("dedup_window", None)
|
|
419
|
+
data.pop("inject", None)
|
|
420
|
+
data["quality_loop"] = quality
|
|
421
|
+
data["spec_workflow"] = {
|
|
422
|
+
"schema_version": 1, "enforcement": "required", "drift_check": True,
|
|
423
|
+
"scope_expansion_check": True, "metrics": True, "catalog": {"dedup_window": 5},
|
|
424
|
+
}
|
|
425
|
+
path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _write_target_ignore(staging: Path) -> None:
|
|
429
|
+
path = staging / ".code-flow/.gitignore"
|
|
430
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
431
|
+
block = (
|
|
432
|
+
"# >>> code-flow:runtime schema=1\n.active-task.json\n.active-task.lock\n"
|
|
433
|
+
".catalog-state.json\nmigrations/\nspecs/_session/\n"
|
|
434
|
+
"# <<< code-flow:runtime schema=1\n"
|
|
435
|
+
)
|
|
436
|
+
lines = [line for line in existing.splitlines() if line != ".inject-state"]
|
|
437
|
+
if "# >>> code-flow:runtime schema=1" not in existing:
|
|
438
|
+
path.write_text("\n".join(lines).rstrip() + "\n" + block, encoding="utf-8")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _target_manifest(staging: Path) -> list[dict[str, str]]:
|
|
442
|
+
return [
|
|
443
|
+
{"path": _relative(staging, path), "sha256": _sha256(path)}
|
|
444
|
+
for path in sorted(staging.rglob("*")) if path.is_file()
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def stage_plan(plan: Mapping[str, object], staging_root: str) -> dict[str, object]:
|
|
449
|
+
unresolved = plan.get("unresolved")
|
|
450
|
+
if not isinstance(unresolved, list) or unresolved:
|
|
451
|
+
raise ValueError("prepared plan has unresolved items")
|
|
452
|
+
project_value = plan.get("project_root")
|
|
453
|
+
if not isinstance(project_value, str):
|
|
454
|
+
raise ValueError("prepared plan missing project_root")
|
|
455
|
+
staging = Path(staging_root).resolve()
|
|
456
|
+
if not staging.is_dir() or any(staging.iterdir()):
|
|
457
|
+
raise ValueError("staging root must be a pre-created empty directory")
|
|
458
|
+
_copy_source(Path(project_value), staging)
|
|
459
|
+
_copy_runtime(staging)
|
|
460
|
+
_apply_layout(staging, plan.get("layout_transforms", []))
|
|
461
|
+
_write_contexts(staging, plan)
|
|
462
|
+
_write_target_config(staging)
|
|
463
|
+
_write_target_ignore(staging)
|
|
464
|
+
_write_agent_files(Path(project_value), staging)
|
|
465
|
+
_write_adapters(Path(project_value), staging)
|
|
466
|
+
inject_state = staging / ".code-flow/.inject-state"
|
|
467
|
+
if inject_state.exists():
|
|
468
|
+
inject_state.unlink()
|
|
469
|
+
return {
|
|
470
|
+
"targets": _target_manifest(staging),
|
|
471
|
+
"deletions": [
|
|
472
|
+
".code-flow/.inject-state", ".code-flow/specs/_session",
|
|
473
|
+
".claude/commands/cf-inject.md", ".costrict/commands/cf-inject.md",
|
|
474
|
+
".opencode/commands/cf-inject.md", ".agents/skills/cf-inject",
|
|
475
|
+
],
|
|
476
|
+
"residue": ["inject.mode", "injected_specs", "cf-inject"],
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _parser() -> argparse.ArgumentParser:
|
|
481
|
+
parser = argparse.ArgumentParser(prog="cf_spec_migrate.py")
|
|
482
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
483
|
+
command = commands.add_parser("preflight")
|
|
484
|
+
command.add_argument("--root", required=True)
|
|
485
|
+
command.add_argument("--json", action="store_true")
|
|
486
|
+
stage = commands.add_parser("stage")
|
|
487
|
+
stage.add_argument("--plan", required=True)
|
|
488
|
+
stage.add_argument("--staging", required=True)
|
|
489
|
+
stage.add_argument("--json", action="store_true")
|
|
490
|
+
return parser
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _load_plan(path: str) -> Mapping[str, object]:
|
|
494
|
+
value = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
495
|
+
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
496
|
+
raise ValueError("migration plan must be a mapping")
|
|
497
|
+
return value
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
501
|
+
try:
|
|
502
|
+
args = _parser().parse_args(argv)
|
|
503
|
+
result = preflight(args.root) if args.command == "preflight" else stage_plan(_load_plan(args.plan), args.staging)
|
|
504
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
505
|
+
return 0
|
|
506
|
+
except Exception as exc:
|
|
507
|
+
stdout.write(json.dumps({"ready": False, "error": str(exc)}, ensure_ascii=False))
|
|
508
|
+
return 2
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
if __name__ == "__main__":
|
|
512
|
+
raise SystemExit(main())
|