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,384 @@
|
|
|
1
|
+
"""Frontmatter management with absolute consistency enforcement.
|
|
2
|
+
|
|
3
|
+
This module provides the ONLY way to read and write YAML frontmatter
|
|
4
|
+
in spec-kitty markdown files. All frontmatter operations MUST go through
|
|
5
|
+
these functions to ensure absolute consistency.
|
|
6
|
+
|
|
7
|
+
LLMs and scripts should NEVER manually edit YAML frontmatter.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
from ruamel.yaml import YAML
|
|
18
|
+
from ruamel.yaml.comments import CommentedMap
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FrontmatterError(Exception):
|
|
22
|
+
"""Error in frontmatter operations."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FrontmatterManager:
|
|
27
|
+
"""Manages YAML frontmatter with enforced consistency.
|
|
28
|
+
|
|
29
|
+
Rules:
|
|
30
|
+
1. Always use ruamel.yaml for parsing/writing
|
|
31
|
+
2. Never quote scalar values (let YAML decide)
|
|
32
|
+
3. Use consistent indentation (2 spaces)
|
|
33
|
+
4. Preserve comments
|
|
34
|
+
5. Sort keys in consistent order
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# Standard field order for work package frontmatter
|
|
38
|
+
WP_FIELD_ORDER = [
|
|
39
|
+
"work_package_id",
|
|
40
|
+
"title",
|
|
41
|
+
"lane",
|
|
42
|
+
"dependencies", # List of WP IDs this WP depends on (e.g., ['WP01', 'WP02'])
|
|
43
|
+
"base_branch", # Git branch this WP was created from (e.g., "010-feature-WP01" or "main")
|
|
44
|
+
"base_commit", # Git commit SHA this WP was created from (snapshot for validation)
|
|
45
|
+
"created_at", # ISO timestamp when workspace was created
|
|
46
|
+
"subtasks",
|
|
47
|
+
"phase",
|
|
48
|
+
"assignee",
|
|
49
|
+
"agent",
|
|
50
|
+
"shell_pid",
|
|
51
|
+
"review_status",
|
|
52
|
+
"reviewed_by",
|
|
53
|
+
"history",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
def __init__(self):
|
|
57
|
+
"""Initialize with ruamel.yaml configured for consistency."""
|
|
58
|
+
self.yaml = YAML()
|
|
59
|
+
self.yaml.default_flow_style = False
|
|
60
|
+
self.yaml.preserve_quotes = False # Don't preserve quotes - let YAML decide
|
|
61
|
+
self.yaml.width = 4096 # Prevent line wrapping
|
|
62
|
+
self.yaml.indent(mapping=2, sequence=2, offset=0)
|
|
63
|
+
|
|
64
|
+
def read(self, file_path: Path) -> tuple[Dict[str, Any], str]:
|
|
65
|
+
"""Read frontmatter and body from a markdown file.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
file_path: Path to markdown file
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Tuple of (frontmatter_dict, body_text)
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
FrontmatterError: If file has no frontmatter or is malformed
|
|
75
|
+
"""
|
|
76
|
+
if not file_path.exists():
|
|
77
|
+
raise FrontmatterError(f"File not found: {file_path}")
|
|
78
|
+
|
|
79
|
+
content = file_path.read_text(encoding="utf-8-sig")
|
|
80
|
+
|
|
81
|
+
if not content.startswith("---"):
|
|
82
|
+
raise FrontmatterError(f"File has no frontmatter: {file_path}")
|
|
83
|
+
|
|
84
|
+
# Find closing ---
|
|
85
|
+
lines = content.split("\n")
|
|
86
|
+
closing_idx = -1
|
|
87
|
+
for i, line in enumerate(lines[1:], start=1):
|
|
88
|
+
if line.strip() == "---":
|
|
89
|
+
closing_idx = i
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
if closing_idx == -1:
|
|
93
|
+
raise FrontmatterError(f"Malformed frontmatter (no closing ---): {file_path}")
|
|
94
|
+
|
|
95
|
+
# Parse frontmatter
|
|
96
|
+
frontmatter_text = "\n".join(lines[1:closing_idx])
|
|
97
|
+
try:
|
|
98
|
+
frontmatter = self.yaml.load(frontmatter_text)
|
|
99
|
+
if frontmatter is None:
|
|
100
|
+
frontmatter = {}
|
|
101
|
+
except Exception as e:
|
|
102
|
+
raise FrontmatterError(f"Invalid YAML in {file_path}: {e}")
|
|
103
|
+
|
|
104
|
+
# Ensure dependencies field exists for WP files only (backward compatibility with pre-0.11.0)
|
|
105
|
+
if file_path.name.startswith("WP") and "dependencies" not in frontmatter:
|
|
106
|
+
frontmatter["dependencies"] = []
|
|
107
|
+
|
|
108
|
+
# Get body (everything after closing ---)
|
|
109
|
+
body = "\n".join(lines[closing_idx + 1:])
|
|
110
|
+
|
|
111
|
+
return frontmatter, body
|
|
112
|
+
|
|
113
|
+
def write(self, file_path: Path, frontmatter: Dict[str, Any], body: str) -> None:
|
|
114
|
+
"""Write frontmatter and body to a markdown file.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
file_path: Path to markdown file
|
|
118
|
+
frontmatter: Dictionary of frontmatter fields
|
|
119
|
+
body: Body text (everything after frontmatter)
|
|
120
|
+
"""
|
|
121
|
+
# Normalize frontmatter (sort keys, clean values)
|
|
122
|
+
normalized = self._normalize_frontmatter(frontmatter)
|
|
123
|
+
|
|
124
|
+
# Write to string buffer first
|
|
125
|
+
import io
|
|
126
|
+
buffer = io.StringIO()
|
|
127
|
+
buffer.write("---\n")
|
|
128
|
+
self.yaml.dump(normalized, buffer)
|
|
129
|
+
buffer.write("---\n")
|
|
130
|
+
buffer.write(body)
|
|
131
|
+
|
|
132
|
+
# Write to file
|
|
133
|
+
file_path.write_text(buffer.getvalue(), encoding="utf-8")
|
|
134
|
+
|
|
135
|
+
def update_field(self, file_path: Path, field: str, value: Any) -> None:
|
|
136
|
+
"""Update a single field in frontmatter.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
file_path: Path to markdown file
|
|
140
|
+
field: Field name to update
|
|
141
|
+
value: New value for field
|
|
142
|
+
"""
|
|
143
|
+
frontmatter, body = self.read(file_path)
|
|
144
|
+
frontmatter[field] = value
|
|
145
|
+
self.write(file_path, frontmatter, body)
|
|
146
|
+
|
|
147
|
+
def update_fields(self, file_path: Path, updates: Dict[str, Any]) -> None:
|
|
148
|
+
"""Update multiple fields in frontmatter.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
file_path: Path to markdown file
|
|
152
|
+
updates: Dictionary of field updates
|
|
153
|
+
"""
|
|
154
|
+
frontmatter, body = self.read(file_path)
|
|
155
|
+
frontmatter.update(updates)
|
|
156
|
+
self.write(file_path, frontmatter, body)
|
|
157
|
+
|
|
158
|
+
def get_field(self, file_path: Path, field: str, default: Any = None) -> Any:
|
|
159
|
+
"""Get a single field from frontmatter.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
file_path: Path to markdown file
|
|
163
|
+
field: Field name to get
|
|
164
|
+
default: Default value if field doesn't exist
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Field value or default
|
|
168
|
+
"""
|
|
169
|
+
frontmatter, _ = self.read(file_path)
|
|
170
|
+
return frontmatter.get(field, default)
|
|
171
|
+
|
|
172
|
+
def add_history_entry(
|
|
173
|
+
self,
|
|
174
|
+
file_path: Path,
|
|
175
|
+
action: str,
|
|
176
|
+
agent: Optional[str] = None,
|
|
177
|
+
note: Optional[str] = None
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Add an entry to the history field.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
file_path: Path to markdown file
|
|
183
|
+
action: Action description (e.g., "moved to for_review")
|
|
184
|
+
agent: Agent name (optional)
|
|
185
|
+
note: Additional note (optional)
|
|
186
|
+
"""
|
|
187
|
+
frontmatter, body = self.read(file_path)
|
|
188
|
+
|
|
189
|
+
# Get or create history list
|
|
190
|
+
history = frontmatter.get("history", [])
|
|
191
|
+
if not isinstance(history, list):
|
|
192
|
+
history = []
|
|
193
|
+
|
|
194
|
+
# Create entry
|
|
195
|
+
entry = {
|
|
196
|
+
"timestamp": datetime.now().isoformat(),
|
|
197
|
+
"action": action,
|
|
198
|
+
}
|
|
199
|
+
if agent:
|
|
200
|
+
entry["agent"] = agent
|
|
201
|
+
if note:
|
|
202
|
+
entry["note"] = note
|
|
203
|
+
|
|
204
|
+
history.append(entry)
|
|
205
|
+
frontmatter["history"] = history
|
|
206
|
+
|
|
207
|
+
self.write(file_path, frontmatter, body)
|
|
208
|
+
|
|
209
|
+
def _normalize_frontmatter(self, frontmatter: Dict[str, Any]) -> CommentedMap:
|
|
210
|
+
"""Normalize frontmatter for consistent output.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
frontmatter: Raw frontmatter dictionary
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Normalized CommentedMap with consistent field order
|
|
217
|
+
"""
|
|
218
|
+
# Create ordered map
|
|
219
|
+
normalized = CommentedMap()
|
|
220
|
+
|
|
221
|
+
# Add fields in standard order (if they exist)
|
|
222
|
+
for field in self.WP_FIELD_ORDER:
|
|
223
|
+
if field in frontmatter:
|
|
224
|
+
normalized[field] = frontmatter[field]
|
|
225
|
+
|
|
226
|
+
# Add any remaining fields (alphabetically)
|
|
227
|
+
remaining = sorted(set(frontmatter.keys()) - set(self.WP_FIELD_ORDER))
|
|
228
|
+
for field in remaining:
|
|
229
|
+
normalized[field] = frontmatter[field]
|
|
230
|
+
|
|
231
|
+
return normalized
|
|
232
|
+
|
|
233
|
+
def _validate_dependencies(self, dependencies: Any) -> list[str]:
|
|
234
|
+
"""Validate dependencies field format.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
dependencies: Dependencies value to validate
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
List of validation errors (empty if valid)
|
|
241
|
+
"""
|
|
242
|
+
errors = []
|
|
243
|
+
|
|
244
|
+
if not isinstance(dependencies, list):
|
|
245
|
+
errors.append(f"dependencies must be a list, got {type(dependencies).__name__}")
|
|
246
|
+
return errors
|
|
247
|
+
|
|
248
|
+
wp_pattern = re.compile(r'^WP\d{2}$')
|
|
249
|
+
seen = set()
|
|
250
|
+
|
|
251
|
+
for dep in dependencies:
|
|
252
|
+
if not isinstance(dep, str):
|
|
253
|
+
errors.append(f"Dependency must be string, got {type(dep).__name__}")
|
|
254
|
+
elif not wp_pattern.match(dep):
|
|
255
|
+
errors.append(f"Invalid WP ID format: {dep} (must be WP## like WP01, WP02)")
|
|
256
|
+
elif dep in seen:
|
|
257
|
+
errors.append(f"Duplicate dependency: {dep}")
|
|
258
|
+
else:
|
|
259
|
+
seen.add(dep)
|
|
260
|
+
|
|
261
|
+
return errors
|
|
262
|
+
|
|
263
|
+
def validate(self, file_path: Path) -> list[str]:
|
|
264
|
+
"""Validate frontmatter consistency.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
file_path: Path to markdown file
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
List of validation errors (empty if valid)
|
|
271
|
+
"""
|
|
272
|
+
errors = []
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
frontmatter, _ = self.read(file_path)
|
|
276
|
+
except FrontmatterError as e:
|
|
277
|
+
return [str(e)]
|
|
278
|
+
|
|
279
|
+
# Check for required fields (work packages only)
|
|
280
|
+
if file_path.name.startswith("WP"):
|
|
281
|
+
required = ["work_package_id", "title", "lane"]
|
|
282
|
+
for field in required:
|
|
283
|
+
if field not in frontmatter:
|
|
284
|
+
errors.append(f"Missing required field: {field}")
|
|
285
|
+
|
|
286
|
+
# Validate lane value
|
|
287
|
+
if "lane" in frontmatter:
|
|
288
|
+
valid_lanes = ["planned", "doing", "for_review", "done"]
|
|
289
|
+
if frontmatter["lane"] not in valid_lanes:
|
|
290
|
+
errors.append(
|
|
291
|
+
f"Invalid lane value: {frontmatter['lane']} "
|
|
292
|
+
f"(must be one of: {', '.join(valid_lanes)})"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# Validate dependencies field (if present)
|
|
296
|
+
if "dependencies" in frontmatter:
|
|
297
|
+
dep_errors = self._validate_dependencies(frontmatter["dependencies"])
|
|
298
|
+
errors.extend(dep_errors)
|
|
299
|
+
|
|
300
|
+
return errors
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# Global instance for convenience
|
|
304
|
+
_manager = FrontmatterManager()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# Convenience functions that use the global manager
|
|
308
|
+
def read_frontmatter(file_path: Path) -> tuple[Dict[str, Any], str]:
|
|
309
|
+
"""Read frontmatter and body from a markdown file."""
|
|
310
|
+
return _manager.read(file_path)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def write_frontmatter(file_path: Path, frontmatter: Dict[str, Any], body: str) -> None:
|
|
314
|
+
"""Write frontmatter and body to a markdown file."""
|
|
315
|
+
_manager.write(file_path, frontmatter, body)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def update_field(file_path: Path, field: str, value: Any) -> None:
|
|
319
|
+
"""Update a single field in frontmatter."""
|
|
320
|
+
_manager.update_field(file_path, field, value)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def update_fields(file_path: Path, updates: Dict[str, Any]) -> None:
|
|
324
|
+
"""Update multiple fields in frontmatter."""
|
|
325
|
+
_manager.update_fields(file_path, updates)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_field(file_path: Path, field: str, default: Any = None) -> Any:
|
|
329
|
+
"""Get a single field from frontmatter."""
|
|
330
|
+
return _manager.get_field(file_path, field, default)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def add_history_entry(
|
|
334
|
+
file_path: Path,
|
|
335
|
+
action: str,
|
|
336
|
+
agent: Optional[str] = None,
|
|
337
|
+
note: Optional[str] = None
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Add an entry to the history field."""
|
|
340
|
+
_manager.add_history_entry(file_path, action, agent, note)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def validate_frontmatter(file_path: Path) -> list[str]:
|
|
344
|
+
"""Validate frontmatter consistency."""
|
|
345
|
+
return _manager.validate(file_path)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def normalize_file(file_path: Path) -> bool:
|
|
349
|
+
"""Normalize an existing file's frontmatter.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
file_path: Path to markdown file
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
True if file was modified, False if already normalized
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
# Read current content
|
|
359
|
+
original_content = file_path.read_text(encoding="utf-8-sig")
|
|
360
|
+
|
|
361
|
+
# Read and rewrite (which normalizes)
|
|
362
|
+
frontmatter, body = _manager.read(file_path)
|
|
363
|
+
_manager.write(file_path, frontmatter, body)
|
|
364
|
+
|
|
365
|
+
# Check if changed
|
|
366
|
+
new_content = file_path.read_text(encoding="utf-8-sig")
|
|
367
|
+
return original_content != new_content
|
|
368
|
+
|
|
369
|
+
except FrontmatterError:
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
__all__ = [
|
|
374
|
+
"FrontmatterError",
|
|
375
|
+
"FrontmatterManager",
|
|
376
|
+
"read_frontmatter",
|
|
377
|
+
"write_frontmatter",
|
|
378
|
+
"update_field",
|
|
379
|
+
"update_fields",
|
|
380
|
+
"get_field",
|
|
381
|
+
"add_history_entry",
|
|
382
|
+
"validate_frontmatter",
|
|
383
|
+
"normalize_file",
|
|
384
|
+
]
|