@michaelxwb/code-flow 0.6.5 → 1.0.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 (177) hide show
  1. package/README.md +75 -110
  2. package/bin/code-flow.js +12 -0
  3. package/docs/README.md +19 -0
  4. package/docs/USAGE.md +268 -0
  5. package/docs/v1-design.md +1858 -0
  6. package/docs/v1-implementation.md +186 -0
  7. package/lib/adapters.js +126 -0
  8. package/lib/cli.js +258 -0
  9. package/lib/config.js +85 -0
  10. package/lib/doctor.js +283 -0
  11. package/lib/frontmatter.js +42 -0
  12. package/lib/fs.js +141 -0
  13. package/lib/glob.js +23 -0
  14. package/lib/hook.js +99 -0
  15. package/lib/init.js +132 -0
  16. package/lib/learn.js +170 -0
  17. package/lib/locks.js +52 -0
  18. package/lib/markdown.js +13 -0
  19. package/lib/process.js +116 -0
  20. package/lib/render.js +115 -0
  21. package/lib/repo.js +48 -0
  22. package/lib/runs.js +155 -0
  23. package/lib/session.js +40 -0
  24. package/lib/spec.js +80 -0
  25. package/lib/task.js +258 -0
  26. package/lib/taskdoc.js +308 -0
  27. package/lib/toml.js +140 -0
  28. package/lib/verify.js +168 -0
  29. package/lib/worktree.js +930 -0
  30. package/package.json +21 -9
  31. package/templates/adapters/opencode.mjs +65 -0
  32. package/templates/config.json +39 -0
  33. package/templates/instructions.md +13 -0
  34. package/templates/launcher/bridge.cjs +295 -0
  35. package/templates/launcher/hook.cmd +15 -0
  36. package/templates/launcher/hook.ps1 +10 -0
  37. package/templates/launcher/hook.sh +20 -0
  38. package/templates/skills/cf-check.md +19 -0
  39. package/templates/skills/cf-init.md +16 -0
  40. package/templates/skills/cf-spec.md +20 -0
  41. package/templates/skills/cf-task-align.md +19 -0
  42. package/templates/skills/cf-task-archive.md +20 -0
  43. package/templates/skills/cf-task-plan.md +31 -0
  44. package/templates/skills/cf-task-prd.md +14 -0
  45. package/templates/skills/cf-task-start.md +41 -0
  46. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
  47. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
  48. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
  49. package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
  50. package/templates/workflow/project-guidelines.md +48 -0
  51. package/templates/workflow/scenarios.md +67 -0
  52. package/templates/workflow/task.md +56 -0
  53. package/docs/migrations/spec-workflow-0.6.0.md +0 -42
  54. package/src/adapters/claude/CLAUDE.md +0 -45
  55. package/src/adapters/claude/commands/cf-init.md +0 -264
  56. package/src/adapters/claude/commands/cf-learn.md +0 -291
  57. package/src/adapters/claude/commands/cf-spec.md +0 -67
  58. package/src/adapters/claude/commands/cf-stats.md +0 -86
  59. package/src/adapters/claude/commands/cf-sync.md +0 -25
  60. package/src/adapters/claude/commands/cf-task/align.md +0 -309
  61. package/src/adapters/claude/commands/cf-task/archive.md +0 -161
  62. package/src/adapters/claude/commands/cf-task/block.md +0 -36
  63. package/src/adapters/claude/commands/cf-task/graph.md +0 -80
  64. package/src/adapters/claude/commands/cf-task/note.md +0 -236
  65. package/src/adapters/claude/commands/cf-task/plan.md +0 -363
  66. package/src/adapters/claude/commands/cf-task/prd.md +0 -162
  67. package/src/adapters/claude/commands/cf-task/start.md +0 -304
  68. package/src/adapters/claude/commands/cf-task/status.md +0 -59
  69. package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
  70. package/src/adapters/claude/commands/cf-validate.md +0 -75
  71. package/src/adapters/claude/settings.local.json +0 -50
  72. package/src/adapters/codex/AGENTS.md +0 -45
  73. package/src/adapters/codex/config.toml +0 -3
  74. package/src/adapters/codex/hooks.json +0 -50
  75. package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
  76. package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
  77. package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
  78. package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
  79. package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
  80. package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
  81. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
  82. package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
  83. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
  84. package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
  85. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
  86. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
  87. package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
  88. package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
  89. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
  90. package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
  91. package/src/adapters/costrict/CLAUDE.md +0 -45
  92. package/src/adapters/costrict/commands/cf-init.md +0 -264
  93. package/src/adapters/costrict/commands/cf-learn.md +0 -291
  94. package/src/adapters/costrict/commands/cf-spec.md +0 -67
  95. package/src/adapters/costrict/commands/cf-stats.md +0 -86
  96. package/src/adapters/costrict/commands/cf-sync.md +0 -25
  97. package/src/adapters/costrict/commands/cf-task/align.md +0 -309
  98. package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
  99. package/src/adapters/costrict/commands/cf-task/block.md +0 -36
  100. package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
  101. package/src/adapters/costrict/commands/cf-task/note.md +0 -236
  102. package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
  103. package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
  104. package/src/adapters/costrict/commands/cf-task/start.md +0 -304
  105. package/src/adapters/costrict/commands/cf-task/status.md +0 -59
  106. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
  107. package/src/adapters/costrict/commands/cf-validate.md +0 -75
  108. package/src/adapters/costrict/settings.local.json +0 -50
  109. package/src/adapters/opencode/AGENTS.md +0 -45
  110. package/src/adapters/opencode/commands/cf-init.md +0 -281
  111. package/src/adapters/opencode/commands/cf-learn.md +0 -295
  112. package/src/adapters/opencode/commands/cf-spec.md +0 -59
  113. package/src/adapters/opencode/commands/cf-stats.md +0 -90
  114. package/src/adapters/opencode/commands/cf-sync.md +0 -29
  115. package/src/adapters/opencode/commands/cf-task/align.md +0 -314
  116. package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
  117. package/src/adapters/opencode/commands/cf-task/block.md +0 -40
  118. package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
  119. package/src/adapters/opencode/commands/cf-task/note.md +0 -240
  120. package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
  121. package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
  122. package/src/adapters/opencode/commands/cf-task/start.md +0 -309
  123. package/src/adapters/opencode/commands/cf-task/status.md +0 -63
  124. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
  125. package/src/adapters/opencode/commands/cf-validate.md +0 -79
  126. package/src/adapters/opencode/opencode.json +0 -3
  127. package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
  128. package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
  129. package/src/cli.js +0 -1023
  130. package/src/core/code-flow/config.yml +0 -158
  131. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  132. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
  133. package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
  134. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  135. package/src/core/code-flow/scripts/cf_checks.py +0 -336
  136. package/src/core/code-flow/scripts/cf_core.py +0 -787
  137. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  138. package/src/core/code-flow/scripts/cf_feedback.py +0 -56
  139. package/src/core/code-flow/scripts/cf_log.py +0 -154
  140. package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
  141. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
  142. package/src/core/code-flow/scripts/cf_scan.py +0 -295
  143. package/src/core/code-flow/scripts/cf_session_state.py +0 -37
  144. package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
  145. package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
  146. package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
  147. package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
  148. package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
  149. package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
  150. package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
  151. package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
  152. package/src/core/code-flow/scripts/cf_stats.py +0 -506
  153. package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
  154. package/src/core/code-flow/scripts/cf_sync.py +0 -140
  155. package/src/core/code-flow/scripts/cf_task_index.py +0 -148
  156. package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
  157. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  158. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  159. package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
  160. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
  161. package/src/core/code-flow/scripts/cf_validation.py +0 -81
  162. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  163. package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
  164. package/src/core/code-flow/specs/backend/_map.md +0 -56
  165. package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
  166. package/src/core/code-flow/specs/backend/database.md +0 -51
  167. package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
  168. package/src/core/code-flow/specs/backend/logging.md +0 -50
  169. package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
  170. package/src/core/code-flow/specs/frontend/_map.md +0 -52
  171. package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
  172. package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
  173. package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
  174. package/src/core/code-flow/specs/shared/_map.md +0 -57
  175. package/src/core/code-flow/tasks/.gitkeep +0 -0
  176. package/src/core/code-flow/validation.yml +0 -53
  177. package/src/migrate/spec-workflow.js +0 -238
@@ -1,512 +0,0 @@
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())