@michaelxwb/code-flow 0.5.2 → 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.
Files changed (76) hide show
  1. package/docs/migrations/spec-workflow-0.6.0.md +42 -0
  2. package/package.json +4 -2
  3. package/src/adapters/claude/CLAUDE.md +11 -17
  4. package/src/adapters/claude/commands/cf-init.md +5 -4
  5. package/src/adapters/claude/commands/cf-spec.md +50 -0
  6. package/src/adapters/claude/commands/cf-task/align.md +23 -1
  7. package/src/adapters/claude/commands/cf-task/archive.md +7 -0
  8. package/src/adapters/claude/commands/cf-task/plan.md +16 -0
  9. package/src/adapters/claude/commands/cf-task/prd.md +24 -0
  10. package/src/adapters/claude/commands/cf-task/start.md +13 -10
  11. package/src/adapters/claude/settings.local.json +1 -11
  12. package/src/adapters/codex/AGENTS.md +11 -17
  13. package/src/adapters/codex/hooks.json +6 -4
  14. package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
  15. package/src/adapters/codex/skills/cf-spec/SKILL.md +55 -0
  16. package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
  17. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
  18. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
  19. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
  20. package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
  21. package/src/adapters/costrict/CLAUDE.md +11 -17
  22. package/src/adapters/costrict/commands/cf-init.md +5 -4
  23. package/src/adapters/costrict/commands/cf-spec.md +50 -0
  24. package/src/adapters/costrict/commands/cf-task/align.md +23 -1
  25. package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
  26. package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
  27. package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
  28. package/src/adapters/costrict/commands/cf-task/start.md +13 -10
  29. package/src/adapters/costrict/settings.local.json +1 -11
  30. package/src/adapters/opencode/AGENTS.md +11 -17
  31. package/src/adapters/opencode/commands/cf-init.md +4 -3
  32. package/src/adapters/opencode/commands/cf-spec.md +54 -0
  33. package/src/adapters/opencode/commands/cf-task/align.md +24 -1
  34. package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
  35. package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
  36. package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
  37. package/src/adapters/opencode/commands/cf-task/start.md +14 -10
  38. package/src/adapters/opencode/plugins/code-flow/index.js +0 -1
  39. package/src/cli.js +51 -2
  40. package/src/core/code-flow/config.yml +13 -16
  41. package/src/core/code-flow/scripts/cf_checks.py +30 -0
  42. package/src/core/code-flow/scripts/cf_core.py +12 -255
  43. package/src/core/code-flow/scripts/cf_log.py +8 -0
  44. package/src/core/code-flow/scripts/cf_post_hook.py +33 -9
  45. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +65 -0
  46. package/src/core/code-flow/scripts/cf_spec_context.py +1233 -0
  47. package/src/core/code-flow/scripts/cf_spec_gate.py +224 -0
  48. package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
  49. package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
  50. package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
  51. package/src/core/code-flow/scripts/cf_spec_router.py +145 -0
  52. package/src/core/code-flow/scripts/cf_spec_session.py +122 -0
  53. package/src/core/code-flow/scripts/cf_spec_verify.py +293 -0
  54. package/src/core/code-flow/scripts/cf_stats.py +40 -5
  55. package/src/core/code-flow/scripts/cf_stop_hook.py +22 -2
  56. package/src/core/code-flow/scripts/cf_task_runtime.py +142 -0
  57. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +34 -360
  58. package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
  59. package/src/core/code-flow/specs/backend/database.md +13 -1
  60. package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
  61. package/src/core/code-flow/specs/backend/logging.md +13 -1
  62. package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
  63. package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
  64. package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
  65. package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
  66. package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
  67. package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
  68. package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
  69. package/src/core/code-flow/specs/shared/prd-template.md +14 -3
  70. package/src/migrate/spec-workflow.js +238 -0
  71. package/src/adapters/claude/commands/cf-inject.md +0 -55
  72. package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
  73. package/src/adapters/costrict/commands/cf-inject.md +0 -55
  74. package/src/adapters/opencode/commands/cf-inject.md +0 -59
  75. package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
  76. 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())