spec-kitty-cli 0.12.1__py3-none-any.whl
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.
- spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
- spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
- spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
- spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
- spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
- specify_cli/__init__.py +171 -0
- specify_cli/acceptance.py +627 -0
- specify_cli/agent_utils/README.md +157 -0
- specify_cli/agent_utils/__init__.py +9 -0
- specify_cli/agent_utils/status.py +356 -0
- specify_cli/cli/__init__.py +6 -0
- specify_cli/cli/commands/__init__.py +46 -0
- specify_cli/cli/commands/accept.py +189 -0
- specify_cli/cli/commands/agent/__init__.py +22 -0
- specify_cli/cli/commands/agent/config.py +382 -0
- specify_cli/cli/commands/agent/context.py +191 -0
- specify_cli/cli/commands/agent/feature.py +1057 -0
- specify_cli/cli/commands/agent/release.py +11 -0
- specify_cli/cli/commands/agent/tasks.py +1253 -0
- specify_cli/cli/commands/agent/workflow.py +801 -0
- specify_cli/cli/commands/context.py +246 -0
- specify_cli/cli/commands/dashboard.py +85 -0
- specify_cli/cli/commands/implement.py +973 -0
- specify_cli/cli/commands/init.py +827 -0
- specify_cli/cli/commands/init_help.py +62 -0
- specify_cli/cli/commands/merge.py +755 -0
- specify_cli/cli/commands/mission.py +240 -0
- specify_cli/cli/commands/ops.py +265 -0
- specify_cli/cli/commands/orchestrate.py +640 -0
- specify_cli/cli/commands/repair.py +175 -0
- specify_cli/cli/commands/research.py +165 -0
- specify_cli/cli/commands/sync.py +364 -0
- specify_cli/cli/commands/upgrade.py +249 -0
- specify_cli/cli/commands/validate_encoding.py +186 -0
- specify_cli/cli/commands/validate_tasks.py +186 -0
- specify_cli/cli/commands/verify.py +310 -0
- specify_cli/cli/helpers.py +123 -0
- specify_cli/cli/step_tracker.py +91 -0
- specify_cli/cli/ui.py +192 -0
- specify_cli/core/__init__.py +53 -0
- specify_cli/core/agent_context.py +311 -0
- specify_cli/core/config.py +96 -0
- specify_cli/core/context_validation.py +362 -0
- specify_cli/core/dependency_graph.py +351 -0
- specify_cli/core/git_ops.py +129 -0
- specify_cli/core/multi_parent_merge.py +323 -0
- specify_cli/core/paths.py +260 -0
- specify_cli/core/project_resolver.py +110 -0
- specify_cli/core/stale_detection.py +263 -0
- specify_cli/core/tool_checker.py +79 -0
- specify_cli/core/utils.py +43 -0
- specify_cli/core/vcs/__init__.py +114 -0
- specify_cli/core/vcs/detection.py +341 -0
- specify_cli/core/vcs/exceptions.py +85 -0
- specify_cli/core/vcs/git.py +1304 -0
- specify_cli/core/vcs/jujutsu.py +1208 -0
- specify_cli/core/vcs/protocol.py +285 -0
- specify_cli/core/vcs/types.py +249 -0
- specify_cli/core/version_checker.py +261 -0
- specify_cli/core/worktree.py +506 -0
- specify_cli/dashboard/__init__.py +28 -0
- specify_cli/dashboard/diagnostics.py +204 -0
- specify_cli/dashboard/handlers/__init__.py +17 -0
- specify_cli/dashboard/handlers/api.py +143 -0
- specify_cli/dashboard/handlers/base.py +65 -0
- specify_cli/dashboard/handlers/features.py +390 -0
- specify_cli/dashboard/handlers/router.py +81 -0
- specify_cli/dashboard/handlers/static.py +50 -0
- specify_cli/dashboard/lifecycle.py +541 -0
- specify_cli/dashboard/scanner.py +437 -0
- specify_cli/dashboard/server.py +123 -0
- specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
- specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
- specify_cli/dashboard/static/spec-kitty.png +0 -0
- specify_cli/dashboard/templates/__init__.py +36 -0
- specify_cli/dashboard/templates/index.html +258 -0
- specify_cli/doc_generators.py +621 -0
- specify_cli/doc_state.py +408 -0
- specify_cli/frontmatter.py +384 -0
- specify_cli/gap_analysis.py +915 -0
- specify_cli/gitignore_manager.py +300 -0
- specify_cli/guards.py +145 -0
- specify_cli/legacy_detector.py +83 -0
- specify_cli/manifest.py +286 -0
- specify_cli/merge/__init__.py +63 -0
- specify_cli/merge/executor.py +653 -0
- specify_cli/merge/forecast.py +215 -0
- specify_cli/merge/ordering.py +126 -0
- specify_cli/merge/preflight.py +230 -0
- specify_cli/merge/state.py +185 -0
- specify_cli/merge/status_resolver.py +354 -0
- specify_cli/mission.py +654 -0
- specify_cli/missions/documentation/command-templates/implement.md +309 -0
- specify_cli/missions/documentation/command-templates/plan.md +275 -0
- specify_cli/missions/documentation/command-templates/review.md +344 -0
- specify_cli/missions/documentation/command-templates/specify.md +206 -0
- specify_cli/missions/documentation/command-templates/tasks.md +189 -0
- specify_cli/missions/documentation/mission.yaml +113 -0
- specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
- specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
- specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
- specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
- specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
- specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
- specify_cli/missions/documentation/templates/plan-template.md +269 -0
- specify_cli/missions/documentation/templates/release-template.md +222 -0
- specify_cli/missions/documentation/templates/spec-template.md +172 -0
- specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
- specify_cli/missions/documentation/templates/tasks-template.md +159 -0
- specify_cli/missions/research/command-templates/merge.md +388 -0
- specify_cli/missions/research/command-templates/plan.md +125 -0
- specify_cli/missions/research/command-templates/review.md +144 -0
- specify_cli/missions/research/command-templates/tasks.md +225 -0
- specify_cli/missions/research/mission.yaml +115 -0
- specify_cli/missions/research/templates/data-model-template.md +33 -0
- specify_cli/missions/research/templates/plan-template.md +161 -0
- specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
- specify_cli/missions/research/templates/research/source-register.csv +18 -0
- specify_cli/missions/research/templates/research-template.md +35 -0
- specify_cli/missions/research/templates/spec-template.md +64 -0
- specify_cli/missions/research/templates/task-prompt-template.md +148 -0
- specify_cli/missions/research/templates/tasks-template.md +114 -0
- specify_cli/missions/software-dev/command-templates/accept.md +75 -0
- specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
- specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
- specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
- specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
- specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
- specify_cli/missions/software-dev/command-templates/implement.md +41 -0
- specify_cli/missions/software-dev/command-templates/merge.md +383 -0
- specify_cli/missions/software-dev/command-templates/plan.md +171 -0
- specify_cli/missions/software-dev/command-templates/review.md +32 -0
- specify_cli/missions/software-dev/command-templates/specify.md +321 -0
- specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
- specify_cli/missions/software-dev/mission.yaml +100 -0
- specify_cli/missions/software-dev/templates/plan-template.md +132 -0
- specify_cli/missions/software-dev/templates/spec-template.md +116 -0
- specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
- specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
- specify_cli/orchestrator/__init__.py +75 -0
- specify_cli/orchestrator/agent_config.py +224 -0
- specify_cli/orchestrator/agents/__init__.py +170 -0
- specify_cli/orchestrator/agents/augment.py +112 -0
- specify_cli/orchestrator/agents/base.py +243 -0
- specify_cli/orchestrator/agents/claude.py +112 -0
- specify_cli/orchestrator/agents/codex.py +106 -0
- specify_cli/orchestrator/agents/copilot.py +137 -0
- specify_cli/orchestrator/agents/cursor.py +139 -0
- specify_cli/orchestrator/agents/gemini.py +115 -0
- specify_cli/orchestrator/agents/kilocode.py +94 -0
- specify_cli/orchestrator/agents/opencode.py +132 -0
- specify_cli/orchestrator/agents/qwen.py +96 -0
- specify_cli/orchestrator/config.py +455 -0
- specify_cli/orchestrator/executor.py +642 -0
- specify_cli/orchestrator/integration.py +1230 -0
- specify_cli/orchestrator/monitor.py +898 -0
- specify_cli/orchestrator/scheduler.py +832 -0
- specify_cli/orchestrator/state.py +508 -0
- specify_cli/orchestrator/testing/__init__.py +122 -0
- specify_cli/orchestrator/testing/availability.py +346 -0
- specify_cli/orchestrator/testing/fixtures.py +684 -0
- specify_cli/orchestrator/testing/paths.py +218 -0
- specify_cli/plan_validation.py +107 -0
- specify_cli/scripts/debug-dashboard-scan.py +61 -0
- specify_cli/scripts/tasks/acceptance_support.py +695 -0
- specify_cli/scripts/tasks/task_helpers.py +506 -0
- specify_cli/scripts/tasks/tasks_cli.py +848 -0
- specify_cli/scripts/validate_encoding.py +180 -0
- specify_cli/task_metadata_validation.py +274 -0
- specify_cli/tasks_support.py +447 -0
- specify_cli/template/__init__.py +47 -0
- specify_cli/template/asset_generator.py +206 -0
- specify_cli/template/github_client.py +334 -0
- specify_cli/template/manager.py +193 -0
- specify_cli/template/renderer.py +99 -0
- specify_cli/templates/AGENTS.md +190 -0
- specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
- specify_cli/templates/agent-file-template.md +35 -0
- specify_cli/templates/checklist-template.md +42 -0
- specify_cli/templates/claudeignore-template +58 -0
- specify_cli/templates/command-templates/accept.md +141 -0
- specify_cli/templates/command-templates/analyze.md +253 -0
- specify_cli/templates/command-templates/checklist.md +352 -0
- specify_cli/templates/command-templates/clarify.md +224 -0
- specify_cli/templates/command-templates/constitution.md +432 -0
- specify_cli/templates/command-templates/dashboard.md +175 -0
- specify_cli/templates/command-templates/implement.md +190 -0
- specify_cli/templates/command-templates/merge.md +374 -0
- specify_cli/templates/command-templates/plan.md +171 -0
- specify_cli/templates/command-templates/research.md +88 -0
- specify_cli/templates/command-templates/review.md +510 -0
- specify_cli/templates/command-templates/specify.md +321 -0
- specify_cli/templates/command-templates/status.md +92 -0
- specify_cli/templates/command-templates/tasks.md +199 -0
- specify_cli/templates/git-hooks/pre-commit +22 -0
- specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
- specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
- specify_cli/templates/plan-template.md +108 -0
- specify_cli/templates/spec-template.md +118 -0
- specify_cli/templates/task-prompt-template.md +165 -0
- specify_cli/templates/tasks-template.md +161 -0
- specify_cli/templates/vscode-settings.json +13 -0
- specify_cli/text_sanitization.py +225 -0
- specify_cli/upgrade/__init__.py +18 -0
- specify_cli/upgrade/detector.py +239 -0
- specify_cli/upgrade/metadata.py +182 -0
- specify_cli/upgrade/migrations/__init__.py +65 -0
- specify_cli/upgrade/migrations/base.py +80 -0
- specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
- specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
- specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
- specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
- specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
- specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
- specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
- specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
- specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
- specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
- specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
- specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
- specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
- specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
- specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
- specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
- specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
- specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
- specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
- specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
- specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
- specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
- specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
- specify_cli/upgrade/registry.py +121 -0
- specify_cli/upgrade/runner.py +284 -0
- specify_cli/validators/__init__.py +14 -0
- specify_cli/validators/paths.py +154 -0
- specify_cli/validators/research.py +428 -0
- specify_cli/verify_enhanced.py +270 -0
- specify_cli/workspace_context.py +224 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Migration: Update scripts to latest version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..registry import MigrationRegistry
|
|
9
|
+
from .base import BaseMigration, MigrationResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@MigrationRegistry.register
|
|
13
|
+
class UpdateScriptsMigration(BaseMigration):
|
|
14
|
+
"""Update .kittify/scripts/ to latest version.
|
|
15
|
+
|
|
16
|
+
The create-new-feature.sh script was fixed in v0.7.3 to scan both
|
|
17
|
+
kitty-specs/ AND .worktrees/ for existing feature numbers. Projects
|
|
18
|
+
initialized before v0.7.3 have the old script that only scans kitty-specs/,
|
|
19
|
+
causing duplicate feature numbers when using worktrees.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
migration_id = "0.7.3_update_scripts"
|
|
23
|
+
description = "Update scripts to fix worktree feature numbering"
|
|
24
|
+
target_version = "0.7.3"
|
|
25
|
+
|
|
26
|
+
def detect(self, project_path: Path) -> bool:
|
|
27
|
+
"""Check if project has old scripts that need updating."""
|
|
28
|
+
script_path = project_path / ".kittify" / "scripts" / "bash" / "create-new-feature.sh"
|
|
29
|
+
|
|
30
|
+
if not script_path.exists():
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
# Check if script has the fix (scans .worktrees/)
|
|
34
|
+
try:
|
|
35
|
+
content = script_path.read_text(encoding="utf-8")
|
|
36
|
+
# Old script doesn't have this line
|
|
37
|
+
if "Also scan .worktrees/" not in content:
|
|
38
|
+
return True
|
|
39
|
+
except (OSError, UnicodeDecodeError):
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
def can_apply(self, project_path: Path) -> tuple[bool, str]:
|
|
45
|
+
"""Check if we can apply this migration."""
|
|
46
|
+
kittify_dir = project_path / ".kittify"
|
|
47
|
+
if not kittify_dir.exists():
|
|
48
|
+
return False, "No .kittify directory (not a spec-kitty project)"
|
|
49
|
+
|
|
50
|
+
return True, ""
|
|
51
|
+
|
|
52
|
+
def apply(self, project_path: Path, dry_run: bool = False) -> MigrationResult:
|
|
53
|
+
"""Copy updated scripts from package templates."""
|
|
54
|
+
changes: list[str] = []
|
|
55
|
+
warnings: list[str] = []
|
|
56
|
+
errors: list[str] = []
|
|
57
|
+
|
|
58
|
+
import specify_cli
|
|
59
|
+
|
|
60
|
+
pkg_root = Path(specify_cli.__file__).parent
|
|
61
|
+
|
|
62
|
+
# Scripts to update
|
|
63
|
+
scripts = [
|
|
64
|
+
("scripts/bash/create-new-feature.sh", ".kittify/scripts/bash/create-new-feature.sh"),
|
|
65
|
+
("scripts/bash/common.sh", ".kittify/scripts/bash/common.sh"),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
any_scripts_found = False
|
|
69
|
+
for src_rel, _ in scripts:
|
|
70
|
+
if (pkg_root / src_rel).exists():
|
|
71
|
+
any_scripts_found = True
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
if not any_scripts_found:
|
|
75
|
+
warnings.append(
|
|
76
|
+
"Bash scripts not found in package (removed in later version or never existed). "
|
|
77
|
+
"If you need script updates, they may have been handled by migration 0.10.0 cleanup. "
|
|
78
|
+
"This is not an error."
|
|
79
|
+
)
|
|
80
|
+
return MigrationResult(
|
|
81
|
+
success=True,
|
|
82
|
+
changes_made=[],
|
|
83
|
+
errors=[],
|
|
84
|
+
warnings=warnings,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for src_rel, dest_rel in scripts:
|
|
88
|
+
src = pkg_root / src_rel
|
|
89
|
+
dest = project_path / dest_rel
|
|
90
|
+
|
|
91
|
+
if not src.exists():
|
|
92
|
+
warnings.append(f"Template {src_rel} not found in package")
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
if not dest.parent.exists():
|
|
96
|
+
if not dry_run:
|
|
97
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
if dry_run:
|
|
100
|
+
changes.append(f"Would update {dest_rel}")
|
|
101
|
+
else:
|
|
102
|
+
try:
|
|
103
|
+
shutil.copy2(src, dest)
|
|
104
|
+
changes.append(f"Updated {dest_rel}")
|
|
105
|
+
except OSError as e:
|
|
106
|
+
errors.append(f"Failed to update {dest_rel}: {e}")
|
|
107
|
+
|
|
108
|
+
success = len(errors) == 0
|
|
109
|
+
return MigrationResult(
|
|
110
|
+
success=success,
|
|
111
|
+
changes_made=changes,
|
|
112
|
+
errors=errors,
|
|
113
|
+
warnings=warnings,
|
|
114
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Migration: Remove deprecated .kittify/active-mission file/symlink."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..registry import MigrationRegistry
|
|
8
|
+
from .base import BaseMigration, MigrationResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@MigrationRegistry.register
|
|
12
|
+
class RemoveActiveMissionMigration(BaseMigration):
|
|
13
|
+
"""Remove deprecated .kittify/active-mission file or symlink.
|
|
14
|
+
|
|
15
|
+
As of v0.8.0, missions are selected per-feature during /spec-kitty.specify.
|
|
16
|
+
The project-level .kittify/active-mission symlink/file is no longer used.
|
|
17
|
+
|
|
18
|
+
This migration removes the obsolete active-mission file and informs the
|
|
19
|
+
user about the new per-feature mission workflow.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
migration_id = "0.8.0_remove_active_mission"
|
|
23
|
+
description = "Remove deprecated .kittify/active-mission (missions are now per-feature)"
|
|
24
|
+
target_version = "0.8.0"
|
|
25
|
+
|
|
26
|
+
def detect(self, project_path: Path) -> bool:
|
|
27
|
+
"""Check if .kittify/active-mission exists."""
|
|
28
|
+
kittify_dir = project_path / ".kittify"
|
|
29
|
+
active_mission = kittify_dir / "active-mission"
|
|
30
|
+
|
|
31
|
+
# Check for file, symlink, or broken symlink
|
|
32
|
+
return active_mission.exists() or active_mission.is_symlink()
|
|
33
|
+
|
|
34
|
+
def can_apply(self, project_path: Path) -> tuple[bool, str]:
|
|
35
|
+
"""Migration can always be applied if active-mission exists."""
|
|
36
|
+
return True, ""
|
|
37
|
+
|
|
38
|
+
def apply(self, project_path: Path, dry_run: bool = False) -> MigrationResult:
|
|
39
|
+
"""Remove .kittify/active-mission file or symlink."""
|
|
40
|
+
changes: list[str] = []
|
|
41
|
+
warnings: list[str] = []
|
|
42
|
+
errors: list[str] = []
|
|
43
|
+
|
|
44
|
+
kittify_dir = project_path / ".kittify"
|
|
45
|
+
active_mission = kittify_dir / "active-mission"
|
|
46
|
+
|
|
47
|
+
if active_mission.exists() or active_mission.is_symlink():
|
|
48
|
+
if dry_run:
|
|
49
|
+
changes.append(
|
|
50
|
+
"Would remove .kittify/active-mission"
|
|
51
|
+
)
|
|
52
|
+
changes.append(
|
|
53
|
+
" -> Missions are now selected per-feature during /spec-kitty.specify"
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
try:
|
|
57
|
+
active_mission.unlink()
|
|
58
|
+
changes.append(
|
|
59
|
+
"Removed deprecated .kittify/active-mission"
|
|
60
|
+
)
|
|
61
|
+
changes.append(
|
|
62
|
+
" -> Missions are now selected per-feature during /spec-kitty.specify"
|
|
63
|
+
)
|
|
64
|
+
changes.append(
|
|
65
|
+
" -> Existing features will use 'software-dev' mission by default"
|
|
66
|
+
)
|
|
67
|
+
except OSError as e:
|
|
68
|
+
errors.append(
|
|
69
|
+
f"Failed to remove .kittify/active-mission: {e}"
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
warnings.append(
|
|
73
|
+
"No .kittify/active-mission found (already migrated or new project)"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
success = len(errors) == 0
|
|
77
|
+
return MigrationResult(
|
|
78
|
+
success=success,
|
|
79
|
+
changes_made=changes,
|
|
80
|
+
errors=errors,
|
|
81
|
+
warnings=warnings,
|
|
82
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Migration: Create AGENTS.md symlink in worktrees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..registry import MigrationRegistry
|
|
10
|
+
from .base import BaseMigration, MigrationResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@MigrationRegistry.register
|
|
14
|
+
class WorktreeAgentsSymlinkMigration(BaseMigration):
|
|
15
|
+
"""Create .kittify/AGENTS.md symlink in worktrees.
|
|
16
|
+
|
|
17
|
+
Worktrees need access to the main repo's .kittify/AGENTS.md file
|
|
18
|
+
for command templates that reference it. Since .kittify/ is gitignored,
|
|
19
|
+
worktrees don't automatically have it.
|
|
20
|
+
|
|
21
|
+
This migration creates a symlink from each worktree's
|
|
22
|
+
.kittify/AGENTS.md to the main repo's .kittify/AGENTS.md.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
migration_id = "0.8.0_worktree_agents_symlink"
|
|
26
|
+
description = "Create .kittify/AGENTS.md symlink in worktrees"
|
|
27
|
+
target_version = "0.8.0"
|
|
28
|
+
|
|
29
|
+
def detect(self, project_path: Path) -> bool:
|
|
30
|
+
"""Check if any worktrees are missing .kittify/AGENTS.md."""
|
|
31
|
+
worktrees_dir = project_path / ".worktrees"
|
|
32
|
+
main_agents = project_path / ".kittify" / "AGENTS.md"
|
|
33
|
+
|
|
34
|
+
# No main AGENTS.md means nothing to symlink
|
|
35
|
+
if not main_agents.exists():
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
if not worktrees_dir.exists():
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
for worktree in worktrees_dir.iterdir():
|
|
42
|
+
if worktree.is_dir() and not worktree.name.startswith('.'):
|
|
43
|
+
wt_agents = worktree / ".kittify" / "AGENTS.md"
|
|
44
|
+
# Check if missing or broken symlink
|
|
45
|
+
if not wt_agents.exists() and not wt_agents.is_symlink():
|
|
46
|
+
return True
|
|
47
|
+
# Also check for broken symlinks
|
|
48
|
+
if wt_agents.is_symlink() and not wt_agents.exists():
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def can_apply(self, project_path: Path) -> tuple[bool, str]:
|
|
54
|
+
"""Check that main repo has AGENTS.md."""
|
|
55
|
+
main_agents = project_path / ".kittify" / "AGENTS.md"
|
|
56
|
+
|
|
57
|
+
if not main_agents.exists():
|
|
58
|
+
return (
|
|
59
|
+
False,
|
|
60
|
+
"Main repo .kittify/AGENTS.md must exist before creating symlinks"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return True, ""
|
|
64
|
+
|
|
65
|
+
def apply(self, project_path: Path, dry_run: bool = False) -> MigrationResult:
|
|
66
|
+
"""Create .kittify/AGENTS.md symlink in all worktrees."""
|
|
67
|
+
changes: list[str] = []
|
|
68
|
+
warnings: list[str] = []
|
|
69
|
+
errors: list[str] = []
|
|
70
|
+
|
|
71
|
+
worktrees_dir = project_path / ".worktrees"
|
|
72
|
+
main_agents = project_path / ".kittify" / "AGENTS.md"
|
|
73
|
+
|
|
74
|
+
if not main_agents.exists():
|
|
75
|
+
warnings.append("Main repo .kittify/AGENTS.md not found, skipping")
|
|
76
|
+
return MigrationResult(
|
|
77
|
+
success=True,
|
|
78
|
+
changes_made=changes,
|
|
79
|
+
errors=errors,
|
|
80
|
+
warnings=warnings,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if worktrees_dir.exists():
|
|
84
|
+
for worktree in worktrees_dir.iterdir():
|
|
85
|
+
if worktree.is_dir() and not worktree.name.startswith('.'):
|
|
86
|
+
wt_kittify = worktree / ".kittify"
|
|
87
|
+
wt_agents = wt_kittify / "AGENTS.md"
|
|
88
|
+
|
|
89
|
+
# Skip if already exists and is valid
|
|
90
|
+
if wt_agents.exists() and not wt_agents.is_symlink():
|
|
91
|
+
warnings.append(
|
|
92
|
+
f"Worktree {worktree.name} has non-symlink AGENTS.md, skipping"
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
if wt_agents.is_symlink() and wt_agents.exists():
|
|
97
|
+
# Valid symlink already exists
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
# Calculate relative path: ../../../.kittify/AGENTS.md
|
|
101
|
+
# From: .worktrees/001-feature/.kittify/AGENTS.md
|
|
102
|
+
# To: .kittify/AGENTS.md
|
|
103
|
+
relative_path = "../../../.kittify/AGENTS.md"
|
|
104
|
+
|
|
105
|
+
if dry_run:
|
|
106
|
+
changes.append(
|
|
107
|
+
f"Would create .kittify/AGENTS.md symlink in worktree {worktree.name}"
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
try:
|
|
111
|
+
# Ensure .kittify directory exists
|
|
112
|
+
wt_kittify.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
|
|
114
|
+
# Remove broken symlink if present
|
|
115
|
+
if wt_agents.is_symlink():
|
|
116
|
+
wt_agents.unlink()
|
|
117
|
+
|
|
118
|
+
# Create the symlink
|
|
119
|
+
# Need to change to the directory to create relative symlink
|
|
120
|
+
original_cwd = os.getcwd()
|
|
121
|
+
try:
|
|
122
|
+
os.chdir(wt_kittify)
|
|
123
|
+
os.symlink(relative_path, "AGENTS.md")
|
|
124
|
+
finally:
|
|
125
|
+
os.chdir(original_cwd)
|
|
126
|
+
|
|
127
|
+
changes.append(
|
|
128
|
+
f"Created .kittify/AGENTS.md symlink in worktree {worktree.name}"
|
|
129
|
+
)
|
|
130
|
+
except OSError as e:
|
|
131
|
+
# Symlink failed (Windows?), try copying instead
|
|
132
|
+
try:
|
|
133
|
+
shutil.copy2(main_agents, wt_agents)
|
|
134
|
+
changes.append(
|
|
135
|
+
f"Copied .kittify/AGENTS.md to worktree {worktree.name} (symlink failed)"
|
|
136
|
+
)
|
|
137
|
+
except OSError as copy_error:
|
|
138
|
+
errors.append(
|
|
139
|
+
f"Failed to create AGENTS.md in {worktree.name}: {e}, copy also failed: {copy_error}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
success = len(errors) == 0
|
|
143
|
+
return MigrationResult(
|
|
144
|
+
success=success,
|
|
145
|
+
changes_made=changes,
|
|
146
|
+
errors=errors,
|
|
147
|
+
warnings=warnings,
|
|
148
|
+
)
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Migration: Convert directory-based task lanes to frontmatter-only lanes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Tuple
|
|
9
|
+
|
|
10
|
+
from ..registry import MigrationRegistry
|
|
11
|
+
from .base import BaseMigration, MigrationResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@MigrationRegistry.register
|
|
15
|
+
class FrontmatterOnlyLanesMigration(BaseMigration):
|
|
16
|
+
"""Migrate from directory-based to frontmatter-only lane management.
|
|
17
|
+
|
|
18
|
+
As of v0.9.0, task lanes are determined solely by the `lane:` field
|
|
19
|
+
in the YAML frontmatter of work package files. The directory structure
|
|
20
|
+
(tasks/planned/, tasks/doing/, etc.) is flattened to a single tasks/
|
|
21
|
+
directory.
|
|
22
|
+
|
|
23
|
+
This migration:
|
|
24
|
+
1. Moves WP files from tasks/{lane}/ to tasks/
|
|
25
|
+
2. Ensures the `lane:` field is set from the source directory
|
|
26
|
+
3. Removes empty lane subdirectories
|
|
27
|
+
4. Processes main kitty-specs/ and all .worktrees/
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
migration_id = "0.9.0_frontmatter_only_lanes"
|
|
31
|
+
description = "Flatten task lanes to frontmatter-only (no more directory-based lanes)"
|
|
32
|
+
target_version = "0.9.0"
|
|
33
|
+
|
|
34
|
+
LANE_DIRS: Tuple[str, ...] = ("planned", "doing", "for_review", "done")
|
|
35
|
+
|
|
36
|
+
# System files to ignore when determining if a directory is empty
|
|
37
|
+
# These files are created automatically by operating systems and should not
|
|
38
|
+
# prevent lane directory cleanup
|
|
39
|
+
IGNORE_FILES = frozenset({
|
|
40
|
+
".gitkeep", # Git placeholder
|
|
41
|
+
".DS_Store", # macOS Finder metadata
|
|
42
|
+
"Thumbs.db", # Windows thumbnail cache
|
|
43
|
+
"desktop.ini", # Windows folder settings
|
|
44
|
+
".directory", # KDE folder settings
|
|
45
|
+
"._*", # macOS resource fork prefix (pattern)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _should_ignore_file(cls, filename: str) -> bool:
|
|
50
|
+
"""Check if a file should be ignored when determining if directory is empty.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
filename: Name of the file to check
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True if file should be ignored (system file), False otherwise
|
|
57
|
+
"""
|
|
58
|
+
# Check exact matches
|
|
59
|
+
if filename in cls.IGNORE_FILES:
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
# Check pattern matches (e.g., ._* for macOS resource forks)
|
|
63
|
+
# Check for macOS resource fork files (._filename)
|
|
64
|
+
if filename.startswith("._"):
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def _get_real_contents(cls, directory: Path) -> List[Path]:
|
|
71
|
+
"""Get directory contents, excluding system files.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
directory: Path to directory to check
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of "real" files (excluding system files like .DS_Store)
|
|
78
|
+
"""
|
|
79
|
+
if not directory.exists() or not directory.is_dir():
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
return [
|
|
83
|
+
item
|
|
84
|
+
for item in directory.iterdir()
|
|
85
|
+
if not cls._should_ignore_file(item.name)
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
def detect(self, project_path: Path) -> bool:
|
|
89
|
+
"""Check if any feature uses legacy directory-based lanes."""
|
|
90
|
+
# Check main kitty-specs/
|
|
91
|
+
main_specs = project_path / "kitty-specs"
|
|
92
|
+
if main_specs.exists():
|
|
93
|
+
for feature_dir in main_specs.iterdir():
|
|
94
|
+
if feature_dir.is_dir() and self._is_legacy_format(feature_dir):
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
# Check .worktrees/
|
|
98
|
+
worktrees_dir = project_path / ".worktrees"
|
|
99
|
+
if worktrees_dir.exists():
|
|
100
|
+
for worktree in worktrees_dir.iterdir():
|
|
101
|
+
if worktree.is_dir():
|
|
102
|
+
wt_specs = worktree / "kitty-specs"
|
|
103
|
+
if wt_specs.exists():
|
|
104
|
+
for feature_dir in wt_specs.iterdir():
|
|
105
|
+
if feature_dir.is_dir() and self._is_legacy_format(feature_dir):
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
def _is_legacy_format(self, feature_path: Path) -> bool:
|
|
111
|
+
"""Check if a feature uses legacy directory-based lanes."""
|
|
112
|
+
tasks_dir = feature_path / "tasks"
|
|
113
|
+
if not tasks_dir.exists():
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
# A feature is legacy if it has ANY lane subdirectories
|
|
117
|
+
# (even if empty - they shouldn't exist in new format)
|
|
118
|
+
for lane in self.LANE_DIRS:
|
|
119
|
+
lane_path = tasks_dir / lane
|
|
120
|
+
if lane_path.exists() and lane_path.is_dir():
|
|
121
|
+
# Directory exists - this is legacy format
|
|
122
|
+
# Check if it has any real content (ignoring system files)
|
|
123
|
+
real_contents = self._get_real_contents(lane_path)
|
|
124
|
+
if real_contents:
|
|
125
|
+
return True
|
|
126
|
+
# Even if only system files, still need migration to remove the directory
|
|
127
|
+
# (The directory itself shouldn't exist in new format)
|
|
128
|
+
elif any(lane_path.iterdir()):
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
def can_apply(self, project_path: Path) -> tuple[bool, str]:
|
|
134
|
+
"""Migration can always be applied if legacy format is detected."""
|
|
135
|
+
return True, ""
|
|
136
|
+
|
|
137
|
+
def apply(self, project_path: Path, dry_run: bool = False) -> MigrationResult:
|
|
138
|
+
"""Migrate all features from directory-based to frontmatter-only lanes."""
|
|
139
|
+
changes: List[str] = []
|
|
140
|
+
warnings: List[str] = []
|
|
141
|
+
errors: List[str] = []
|
|
142
|
+
|
|
143
|
+
features_found = self._find_features_to_migrate(project_path)
|
|
144
|
+
|
|
145
|
+
if not features_found:
|
|
146
|
+
warnings.append("No features need migration - all already use flat structure")
|
|
147
|
+
return MigrationResult(
|
|
148
|
+
success=True,
|
|
149
|
+
changes_made=changes,
|
|
150
|
+
errors=errors,
|
|
151
|
+
warnings=warnings,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
total_migrated = 0
|
|
155
|
+
total_skipped = 0
|
|
156
|
+
|
|
157
|
+
for feature_dir, location_label in features_found:
|
|
158
|
+
feature_changes, feature_warnings, feature_errors, migrated, skipped = (
|
|
159
|
+
self._migrate_feature(feature_dir, location_label, dry_run)
|
|
160
|
+
)
|
|
161
|
+
changes.extend(feature_changes)
|
|
162
|
+
warnings.extend(feature_warnings)
|
|
163
|
+
errors.extend(feature_errors)
|
|
164
|
+
total_migrated += migrated
|
|
165
|
+
total_skipped += skipped
|
|
166
|
+
|
|
167
|
+
if dry_run:
|
|
168
|
+
changes.append(f"Would migrate {total_migrated} WP files across {len(features_found)} features")
|
|
169
|
+
else:
|
|
170
|
+
changes.append(f"Migrated {total_migrated} WP files across {len(features_found)} features")
|
|
171
|
+
|
|
172
|
+
if total_skipped > 0:
|
|
173
|
+
warnings.append(f"Skipped {total_skipped} files (already exist or conflicts)")
|
|
174
|
+
|
|
175
|
+
success = len(errors) == 0
|
|
176
|
+
return MigrationResult(
|
|
177
|
+
success=success,
|
|
178
|
+
changes_made=changes,
|
|
179
|
+
errors=errors,
|
|
180
|
+
warnings=warnings,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _find_features_to_migrate(self, project_path: Path) -> List[Tuple[Path, str]]:
|
|
184
|
+
"""Find all features with legacy format in main repo and worktrees."""
|
|
185
|
+
features: List[Tuple[Path, str]] = []
|
|
186
|
+
|
|
187
|
+
# Scan main kitty-specs/
|
|
188
|
+
main_specs = project_path / "kitty-specs"
|
|
189
|
+
if main_specs.exists():
|
|
190
|
+
for feature_dir in sorted(main_specs.iterdir()):
|
|
191
|
+
if feature_dir.is_dir() and self._is_legacy_format(feature_dir):
|
|
192
|
+
features.append((feature_dir, "main"))
|
|
193
|
+
|
|
194
|
+
# Scan .worktrees/
|
|
195
|
+
worktrees_dir = project_path / ".worktrees"
|
|
196
|
+
if worktrees_dir.exists():
|
|
197
|
+
for worktree in sorted(worktrees_dir.iterdir()):
|
|
198
|
+
if worktree.is_dir():
|
|
199
|
+
wt_specs = worktree / "kitty-specs"
|
|
200
|
+
if wt_specs.exists():
|
|
201
|
+
for feature_dir in sorted(wt_specs.iterdir()):
|
|
202
|
+
if feature_dir.is_dir() and self._is_legacy_format(feature_dir):
|
|
203
|
+
features.append((feature_dir, f"worktree:{worktree.name}"))
|
|
204
|
+
|
|
205
|
+
return features
|
|
206
|
+
|
|
207
|
+
def _migrate_feature(
|
|
208
|
+
self,
|
|
209
|
+
feature_dir: Path,
|
|
210
|
+
location_label: str,
|
|
211
|
+
dry_run: bool,
|
|
212
|
+
) -> Tuple[List[str], List[str], List[str], int, int]:
|
|
213
|
+
"""Migrate a single feature from directory-based to flat structure."""
|
|
214
|
+
changes: List[str] = []
|
|
215
|
+
warnings: List[str] = []
|
|
216
|
+
errors: List[str] = []
|
|
217
|
+
migrated = 0
|
|
218
|
+
skipped = 0
|
|
219
|
+
|
|
220
|
+
tasks_dir = feature_dir / "tasks"
|
|
221
|
+
if not tasks_dir.exists():
|
|
222
|
+
return changes, warnings, errors, migrated, skipped
|
|
223
|
+
|
|
224
|
+
feature_name = feature_dir.name
|
|
225
|
+
changes.append(f"[{location_label}] {feature_name}:")
|
|
226
|
+
|
|
227
|
+
for lane in self.LANE_DIRS:
|
|
228
|
+
lane_dir = tasks_dir / lane
|
|
229
|
+
if not lane_dir.is_dir():
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# Find ALL markdown files, not just WP*.md
|
|
233
|
+
md_files = sorted(lane_dir.glob("*.md"))
|
|
234
|
+
|
|
235
|
+
for md_file in md_files:
|
|
236
|
+
# Skip README.md if it exists
|
|
237
|
+
if md_file.name == "README.md":
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
target = tasks_dir / md_file.name
|
|
241
|
+
|
|
242
|
+
# Check if already exists in flat directory
|
|
243
|
+
if target.exists():
|
|
244
|
+
warnings.append(f" Skip: {md_file.name} already exists in tasks/")
|
|
245
|
+
skipped += 1
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
if dry_run:
|
|
250
|
+
changes.append(f" Would move: {lane}/{md_file.name} → tasks/{md_file.name}")
|
|
251
|
+
else:
|
|
252
|
+
# Read and update content
|
|
253
|
+
content = md_file.read_text(encoding="utf-8-sig")
|
|
254
|
+
updated_content = self._ensure_lane_in_frontmatter(content, lane)
|
|
255
|
+
|
|
256
|
+
# Write to new location
|
|
257
|
+
target.write_text(updated_content, encoding="utf-8")
|
|
258
|
+
|
|
259
|
+
# Remove original
|
|
260
|
+
md_file.unlink()
|
|
261
|
+
|
|
262
|
+
changes.append(f" Moved: {lane}/{md_file.name} → tasks/{md_file.name}")
|
|
263
|
+
|
|
264
|
+
migrated += 1
|
|
265
|
+
|
|
266
|
+
except Exception as e:
|
|
267
|
+
errors.append(f" Error migrating {md_file.name}: {e}")
|
|
268
|
+
|
|
269
|
+
# Clean up empty lane directories
|
|
270
|
+
if not dry_run:
|
|
271
|
+
for lane in self.LANE_DIRS:
|
|
272
|
+
lane_dir = tasks_dir / lane
|
|
273
|
+
if lane_dir.exists() and lane_dir.is_dir():
|
|
274
|
+
# Check for real contents (ignoring system files)
|
|
275
|
+
real_contents = self._get_real_contents(lane_dir)
|
|
276
|
+
if not real_contents:
|
|
277
|
+
# Directory has no real files (only system files like .DS_Store or .gitkeep)
|
|
278
|
+
try:
|
|
279
|
+
# Use shutil.rmtree for more robust removal
|
|
280
|
+
# This will remove the directory and all system files within it
|
|
281
|
+
shutil.rmtree(lane_dir)
|
|
282
|
+
changes.append(f" Removed empty: {lane}/")
|
|
283
|
+
except OSError as e:
|
|
284
|
+
warnings.append(f" Could not remove {lane}/: {e}")
|
|
285
|
+
else:
|
|
286
|
+
for lane in self.LANE_DIRS:
|
|
287
|
+
lane_dir = tasks_dir / lane
|
|
288
|
+
if lane_dir.exists() and lane_dir.is_dir():
|
|
289
|
+
real_contents = self._get_real_contents(lane_dir)
|
|
290
|
+
if not real_contents:
|
|
291
|
+
changes.append(f" Would remove empty: {lane}/")
|
|
292
|
+
|
|
293
|
+
return changes, warnings, errors, migrated, skipped
|
|
294
|
+
|
|
295
|
+
def _ensure_lane_in_frontmatter(self, content: str, expected_lane: str) -> str:
|
|
296
|
+
"""Ensure frontmatter has correct lane field."""
|
|
297
|
+
# Find frontmatter boundaries
|
|
298
|
+
if not content.startswith("---"):
|
|
299
|
+
# No frontmatter, add it
|
|
300
|
+
return f'---\nlane: "{expected_lane}"\n---\n{content}'
|
|
301
|
+
|
|
302
|
+
# Find closing ---
|
|
303
|
+
lines = content.split("\n")
|
|
304
|
+
closing_idx = -1
|
|
305
|
+
for i, line in enumerate(lines[1:], start=1):
|
|
306
|
+
if line.strip() == "---":
|
|
307
|
+
closing_idx = i
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
if closing_idx == -1:
|
|
311
|
+
# Malformed frontmatter, add lane anyway
|
|
312
|
+
return f'---\nlane: "{expected_lane}"\n---\n{content}'
|
|
313
|
+
|
|
314
|
+
frontmatter_lines = lines[1:closing_idx]
|
|
315
|
+
body_lines = lines[closing_idx + 1:]
|
|
316
|
+
|
|
317
|
+
# Check if lane field exists
|
|
318
|
+
lane_pattern = re.compile(r'^lane:\s*(.*)$')
|
|
319
|
+
lane_found = False
|
|
320
|
+
updated_lines = []
|
|
321
|
+
|
|
322
|
+
for line in frontmatter_lines:
|
|
323
|
+
match = lane_pattern.match(line)
|
|
324
|
+
if match:
|
|
325
|
+
lane_found = True
|
|
326
|
+
current_value = match.group(1).strip().strip('"\'')
|
|
327
|
+
if current_value != expected_lane:
|
|
328
|
+
# Replace with expected lane from directory
|
|
329
|
+
updated_lines.append(f'lane: "{expected_lane}"')
|
|
330
|
+
else:
|
|
331
|
+
updated_lines.append(line)
|
|
332
|
+
else:
|
|
333
|
+
updated_lines.append(line)
|
|
334
|
+
|
|
335
|
+
if not lane_found:
|
|
336
|
+
# Insert lane field (before history: if present, otherwise at end)
|
|
337
|
+
insert_idx = len(updated_lines)
|
|
338
|
+
for i, line in enumerate(updated_lines):
|
|
339
|
+
if line.startswith("history:"):
|
|
340
|
+
insert_idx = i
|
|
341
|
+
break
|
|
342
|
+
updated_lines.insert(insert_idx, f'lane: "{expected_lane}"')
|
|
343
|
+
|
|
344
|
+
# Reconstruct document
|
|
345
|
+
result_lines = ["---"] + updated_lines + ["---"] + body_lines
|
|
346
|
+
return "\n".join(result_lines)
|