erk 0.4.5__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.
- erk/__init__.py +12 -0
- erk/__main__.py +6 -0
- erk/agent_docs/__init__.py +5 -0
- erk/agent_docs/models.py +123 -0
- erk/agent_docs/operations.py +666 -0
- erk/artifacts/__init__.py +5 -0
- erk/artifacts/artifact_health.py +623 -0
- erk/artifacts/detection.py +16 -0
- erk/artifacts/discovery.py +343 -0
- erk/artifacts/models.py +63 -0
- erk/artifacts/staleness.py +56 -0
- erk/artifacts/state.py +100 -0
- erk/artifacts/sync.py +624 -0
- erk/cli/__init__.py +0 -0
- erk/cli/activation.py +132 -0
- erk/cli/alias.py +53 -0
- erk/cli/cli.py +221 -0
- erk/cli/commands/__init__.py +0 -0
- erk/cli/commands/admin.py +153 -0
- erk/cli/commands/artifact/__init__.py +1 -0
- erk/cli/commands/artifact/check.py +260 -0
- erk/cli/commands/artifact/group.py +31 -0
- erk/cli/commands/artifact/list_cmd.py +89 -0
- erk/cli/commands/artifact/show.py +62 -0
- erk/cli/commands/artifact/sync_cmd.py +39 -0
- erk/cli/commands/branch/__init__.py +26 -0
- erk/cli/commands/branch/assign_cmd.py +152 -0
- erk/cli/commands/branch/checkout_cmd.py +357 -0
- erk/cli/commands/branch/create_cmd.py +161 -0
- erk/cli/commands/branch/list_cmd.py +82 -0
- erk/cli/commands/branch/unassign_cmd.py +197 -0
- erk/cli/commands/cc/__init__.py +15 -0
- erk/cli/commands/cc/jsonl_cmd.py +20 -0
- erk/cli/commands/cc/session/AGENTS.md +30 -0
- erk/cli/commands/cc/session/CLAUDE.md +1 -0
- erk/cli/commands/cc/session/__init__.py +15 -0
- erk/cli/commands/cc/session/list_cmd.py +167 -0
- erk/cli/commands/cc/session/show_cmd.py +175 -0
- erk/cli/commands/completion.py +89 -0
- erk/cli/commands/completions.py +165 -0
- erk/cli/commands/config.py +327 -0
- erk/cli/commands/docs/__init__.py +1 -0
- erk/cli/commands/docs/group.py +16 -0
- erk/cli/commands/docs/sync.py +121 -0
- erk/cli/commands/docs/validate.py +102 -0
- erk/cli/commands/doctor.py +243 -0
- erk/cli/commands/down.py +171 -0
- erk/cli/commands/exec/__init__.py +1 -0
- erk/cli/commands/exec/group.py +164 -0
- erk/cli/commands/exec/scripts/AGENTS.md +79 -0
- erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
- erk/cli/commands/exec/scripts/__init__.py +5 -0
- erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
- erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
- erk/cli/commands/exec/scripts/check_impl.py +152 -0
- erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
- erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
- erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
- erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
- erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
- erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
- erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
- erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
- erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
- erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
- erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
- erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
- erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
- erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
- erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
- erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
- erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
- erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
- erk/cli/commands/exec/scripts/impl_init.py +158 -0
- erk/cli/commands/exec/scripts/impl_signal.py +375 -0
- erk/cli/commands/exec/scripts/impl_verify.py +49 -0
- erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
- erk/cli/commands/exec/scripts/list_sessions.py +296 -0
- erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
- erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
- erk/cli/commands/exec/scripts/marker.py +163 -0
- erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
- erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
- erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
- erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
- erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
- erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
- erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
- erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
- erk/cli/commands/exec/scripts/quick_submit.py +32 -0
- erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
- erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
- erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
- erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
- erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
- erk/cli/commands/exec/scripts/slot_objective.py +102 -0
- erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
- erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
- erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
- erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
- erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
- erk/cli/commands/implement.py +695 -0
- erk/cli/commands/implement_shared.py +649 -0
- erk/cli/commands/info/__init__.py +14 -0
- erk/cli/commands/info/release_notes_cmd.py +128 -0
- erk/cli/commands/init.py +801 -0
- erk/cli/commands/land_cmd.py +690 -0
- erk/cli/commands/log_cmd.py +137 -0
- erk/cli/commands/md/__init__.py +5 -0
- erk/cli/commands/md/check.py +118 -0
- erk/cli/commands/md/group.py +14 -0
- erk/cli/commands/navigation_helpers.py +430 -0
- erk/cli/commands/objective/__init__.py +16 -0
- erk/cli/commands/objective/list_cmd.py +47 -0
- erk/cli/commands/objective_helpers.py +132 -0
- erk/cli/commands/plan/__init__.py +32 -0
- erk/cli/commands/plan/check_cmd.py +174 -0
- erk/cli/commands/plan/close_cmd.py +69 -0
- erk/cli/commands/plan/create_cmd.py +120 -0
- erk/cli/commands/plan/docs/__init__.py +18 -0
- erk/cli/commands/plan/docs/extract_cmd.py +53 -0
- erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
- erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
- erk/cli/commands/plan/extraction/__init__.py +16 -0
- erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
- erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
- erk/cli/commands/plan/get.py +71 -0
- erk/cli/commands/plan/list_cmd.py +754 -0
- erk/cli/commands/plan/log_cmd.py +440 -0
- erk/cli/commands/plan/start_cmd.py +459 -0
- erk/cli/commands/planner/__init__.py +40 -0
- erk/cli/commands/planner/configure_cmd.py +73 -0
- erk/cli/commands/planner/connect_cmd.py +96 -0
- erk/cli/commands/planner/create_cmd.py +148 -0
- erk/cli/commands/planner/list_cmd.py +51 -0
- erk/cli/commands/planner/register_cmd.py +105 -0
- erk/cli/commands/planner/set_default_cmd.py +23 -0
- erk/cli/commands/planner/unregister_cmd.py +43 -0
- erk/cli/commands/pr/__init__.py +23 -0
- erk/cli/commands/pr/check_cmd.py +112 -0
- erk/cli/commands/pr/checkout_cmd.py +165 -0
- erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
- erk/cli/commands/pr/parse_pr_reference.py +10 -0
- erk/cli/commands/pr/submit_cmd.py +360 -0
- erk/cli/commands/pr/sync_cmd.py +181 -0
- erk/cli/commands/prepare_cwd_recovery.py +60 -0
- erk/cli/commands/project/__init__.py +16 -0
- erk/cli/commands/project/init_cmd.py +91 -0
- erk/cli/commands/run/__init__.py +17 -0
- erk/cli/commands/run/list_cmd.py +189 -0
- erk/cli/commands/run/logs_cmd.py +54 -0
- erk/cli/commands/run/shared.py +19 -0
- erk/cli/commands/shell_integration.py +29 -0
- erk/cli/commands/slot/__init__.py +23 -0
- erk/cli/commands/slot/check_cmd.py +277 -0
- erk/cli/commands/slot/common.py +314 -0
- erk/cli/commands/slot/init_pool_cmd.py +157 -0
- erk/cli/commands/slot/list_cmd.py +228 -0
- erk/cli/commands/slot/repair_cmd.py +190 -0
- erk/cli/commands/stack/__init__.py +23 -0
- erk/cli/commands/stack/consolidate_cmd.py +470 -0
- erk/cli/commands/stack/list_cmd.py +79 -0
- erk/cli/commands/stack/move_cmd.py +309 -0
- erk/cli/commands/stack/split_old/README.md +64 -0
- erk/cli/commands/stack/split_old/__init__.py +5 -0
- erk/cli/commands/stack/split_old/command.py +233 -0
- erk/cli/commands/stack/split_old/display.py +116 -0
- erk/cli/commands/stack/split_old/plan.py +216 -0
- erk/cli/commands/status.py +58 -0
- erk/cli/commands/submit.py +768 -0
- erk/cli/commands/up.py +154 -0
- erk/cli/commands/upgrade.py +82 -0
- erk/cli/commands/wt/__init__.py +29 -0
- erk/cli/commands/wt/checkout_cmd.py +110 -0
- erk/cli/commands/wt/create_cmd.py +998 -0
- erk/cli/commands/wt/current_cmd.py +35 -0
- erk/cli/commands/wt/delete_cmd.py +573 -0
- erk/cli/commands/wt/list_cmd.py +332 -0
- erk/cli/commands/wt/rename_cmd.py +66 -0
- erk/cli/config.py +242 -0
- erk/cli/constants.py +29 -0
- erk/cli/core.py +65 -0
- erk/cli/debug.py +9 -0
- erk/cli/ensure-conversion-tasks.md +288 -0
- erk/cli/ensure.py +628 -0
- erk/cli/github_parsing.py +96 -0
- erk/cli/graphite.py +81 -0
- erk/cli/graphite_command.py +80 -0
- erk/cli/help_formatter.py +345 -0
- erk/cli/output.py +361 -0
- erk/cli/presets/dagster.toml +12 -0
- erk/cli/presets/generic.toml +12 -0
- erk/cli/prompt_hooks_templates/README.md +68 -0
- erk/cli/script_output.py +32 -0
- erk/cli/shell_integration/bash_wrapper.sh +32 -0
- erk/cli/shell_integration/fish_wrapper.fish +39 -0
- erk/cli/shell_integration/handler.py +338 -0
- erk/cli/shell_integration/zsh_wrapper.sh +32 -0
- erk/cli/shell_utils.py +171 -0
- erk/cli/subprocess_utils.py +92 -0
- erk/cli/uvx_detection.py +59 -0
- erk/core/__init__.py +0 -0
- erk/core/claude_executor.py +511 -0
- erk/core/claude_settings.py +317 -0
- erk/core/command_log.py +406 -0
- erk/core/commit_message_generator.py +234 -0
- erk/core/completion.py +10 -0
- erk/core/consolidation_utils.py +177 -0
- erk/core/context.py +570 -0
- erk/core/display/__init__.py +4 -0
- erk/core/display/abc.py +24 -0
- erk/core/display/real.py +30 -0
- erk/core/display_utils.py +526 -0
- erk/core/file_utils.py +87 -0
- erk/core/health_checks.py +1315 -0
- erk/core/health_checks_dogfooder/__init__.py +85 -0
- erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
- erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
- erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
- erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
- erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
- erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
- erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
- erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
- erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
- erk/core/implementation_queue/__init__.py +1 -0
- erk/core/implementation_queue/github/__init__.py +8 -0
- erk/core/implementation_queue/github/abc.py +7 -0
- erk/core/implementation_queue/github/noop.py +38 -0
- erk/core/implementation_queue/github/printing.py +43 -0
- erk/core/implementation_queue/github/real.py +119 -0
- erk/core/init_utils.py +227 -0
- erk/core/output_filter.py +338 -0
- erk/core/plan_store/__init__.py +6 -0
- erk/core/planner/__init__.py +1 -0
- erk/core/planner/registry_abc.py +8 -0
- erk/core/planner/registry_fake.py +129 -0
- erk/core/planner/registry_real.py +195 -0
- erk/core/planner/types.py +7 -0
- erk/core/pr_utils.py +30 -0
- erk/core/release_notes.py +263 -0
- erk/core/repo_discovery.py +126 -0
- erk/core/script_writer.py +41 -0
- erk/core/services/__init__.py +1 -0
- erk/core/services/plan_list_service.py +94 -0
- erk/core/shell.py +51 -0
- erk/core/user_feedback.py +11 -0
- erk/core/version_check.py +55 -0
- erk/core/workflow_display.py +75 -0
- erk/core/worktree_pool.py +190 -0
- erk/core/worktree_utils.py +300 -0
- erk/data/CHANGELOG.md +438 -0
- erk/data/__init__.py +1 -0
- erk/data/claude/agents/devrun.md +180 -0
- erk/data/claude/commands/erk/__init__.py +0 -0
- erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
- erk/data/claude/commands/erk/fix-conflicts.md +25 -0
- erk/data/claude/commands/erk/git-pr-push.md +345 -0
- erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
- erk/data/claude/commands/erk/land.md +193 -0
- erk/data/claude/commands/erk/objective-create.md +370 -0
- erk/data/claude/commands/erk/objective-list.md +34 -0
- erk/data/claude/commands/erk/objective-next-plan.md +220 -0
- erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
- erk/data/claude/commands/erk/plan-implement.md +202 -0
- erk/data/claude/commands/erk/plan-save.md +45 -0
- erk/data/claude/commands/erk/plan-submit.md +39 -0
- erk/data/claude/commands/erk/pr-address.md +367 -0
- erk/data/claude/commands/erk/pr-submit.md +58 -0
- erk/data/claude/skills/dignified-python/SKILL.md +48 -0
- erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
- erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
- erk/data/claude/skills/dignified-python/subprocess.md +99 -0
- erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
- erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
- erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
- erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
- erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
- erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
- erk/data/claude/skills/learned-docs/SKILL.md +362 -0
- erk/data/github/actions/setup-claude-erk/action.yml +11 -0
- erk/data/github/prompts/dignified-python-review.md +125 -0
- erk/data/github/workflows/dignified-python-review.yml +61 -0
- erk/data/github/workflows/erk-impl.yml +251 -0
- erk/hooks/__init__.py +1 -0
- erk/hooks/decorators.py +319 -0
- erk/status/__init__.py +8 -0
- erk/status/collectors/__init__.py +9 -0
- erk/status/collectors/base.py +52 -0
- erk/status/collectors/git.py +76 -0
- erk/status/collectors/github.py +81 -0
- erk/status/collectors/graphite.py +80 -0
- erk/status/collectors/impl.py +145 -0
- erk/status/models/__init__.py +4 -0
- erk/status/models/status_data.py +404 -0
- erk/status/orchestrator.py +169 -0
- erk/status/renderers/__init__.py +5 -0
- erk/status/renderers/simple.py +322 -0
- erk/tui/AGENTS.md +193 -0
- erk/tui/CLAUDE.md +1 -0
- erk/tui/__init__.py +1 -0
- erk/tui/app.py +1404 -0
- erk/tui/commands/__init__.py +1 -0
- erk/tui/commands/executor.py +66 -0
- erk/tui/commands/provider.py +165 -0
- erk/tui/commands/real_executor.py +63 -0
- erk/tui/commands/registry.py +121 -0
- erk/tui/commands/types.py +36 -0
- erk/tui/data/__init__.py +1 -0
- erk/tui/data/provider.py +492 -0
- erk/tui/data/types.py +104 -0
- erk/tui/filtering/__init__.py +1 -0
- erk/tui/filtering/logic.py +43 -0
- erk/tui/filtering/types.py +55 -0
- erk/tui/jsonl_viewer/__init__.py +1 -0
- erk/tui/jsonl_viewer/app.py +61 -0
- erk/tui/jsonl_viewer/models.py +208 -0
- erk/tui/jsonl_viewer/widgets.py +204 -0
- erk/tui/sorting/__init__.py +6 -0
- erk/tui/sorting/logic.py +55 -0
- erk/tui/sorting/types.py +68 -0
- erk/tui/styles/dash.tcss +95 -0
- erk/tui/widgets/__init__.py +1 -0
- erk/tui/widgets/command_output.py +112 -0
- erk/tui/widgets/plan_table.py +276 -0
- erk/tui/widgets/status_bar.py +116 -0
- erk-0.4.5.dist-info/METADATA +376 -0
- erk-0.4.5.dist-info/RECORD +331 -0
- erk-0.4.5.dist-info/WHEEL +4 -0
- erk-0.4.5.dist-info/entry_points.txt +2 -0
- erk-0.4.5.dist-info/licenses/LICENSE.md +3 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
"""Operations for agent documentation management.
|
|
2
|
+
|
|
3
|
+
This module provides functionality for validating and syncing agent documentation
|
|
4
|
+
files with frontmatter metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from erk.agent_docs.models import (
|
|
15
|
+
AgentDocFrontmatter,
|
|
16
|
+
AgentDocValidationResult,
|
|
17
|
+
CategoryInfo,
|
|
18
|
+
CollectedTripwire,
|
|
19
|
+
DocInfo,
|
|
20
|
+
SyncResult,
|
|
21
|
+
Tripwire,
|
|
22
|
+
)
|
|
23
|
+
from erk_shared.subprocess_utils import run_subprocess_with_context
|
|
24
|
+
|
|
25
|
+
AGENT_DOCS_DIR = "docs/learned"
|
|
26
|
+
FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
|
|
27
|
+
|
|
28
|
+
# Category descriptions for root index generation.
|
|
29
|
+
# Format: "Explore when [doing X]. Add docs here for [type of content]."
|
|
30
|
+
# To add a new category, add an entry here and run `erk docs sync`.
|
|
31
|
+
CATEGORY_DESCRIPTIONS: dict[str, str] = {
|
|
32
|
+
"architecture": (
|
|
33
|
+
"Explore when working on core patterns (dry-run, gateways, subprocess, shell integration). "
|
|
34
|
+
"Add docs here for cross-cutting technical patterns."
|
|
35
|
+
),
|
|
36
|
+
"cli": (
|
|
37
|
+
"Explore when building CLI commands or output formatting. "
|
|
38
|
+
"Add docs here for Click patterns and terminal UX."
|
|
39
|
+
),
|
|
40
|
+
"commands": (
|
|
41
|
+
"Explore when creating or optimizing slash commands. "
|
|
42
|
+
"Add docs here for command authoring patterns."
|
|
43
|
+
),
|
|
44
|
+
"erk": (
|
|
45
|
+
"Explore when working with erk-specific workflows (worktrees, PR sync, Graphite). "
|
|
46
|
+
"Add docs here for erk user-facing features."
|
|
47
|
+
),
|
|
48
|
+
"hooks": (
|
|
49
|
+
"Explore when creating or debugging hooks. Add docs here for hook development patterns."
|
|
50
|
+
),
|
|
51
|
+
"planning": (
|
|
52
|
+
"Explore when working with plans, .impl/ folders, or agent delegation. "
|
|
53
|
+
"Add docs here for planning workflow patterns."
|
|
54
|
+
),
|
|
55
|
+
"reference": (
|
|
56
|
+
"Explore for API/format specifications. "
|
|
57
|
+
"Add docs here for reference material that doesn't fit other categories."
|
|
58
|
+
),
|
|
59
|
+
"sessions": (
|
|
60
|
+
"Explore when working with session logs or parallel sessions. "
|
|
61
|
+
"Add docs here for session management patterns."
|
|
62
|
+
),
|
|
63
|
+
"testing": (
|
|
64
|
+
"Explore when writing tests or debugging test infrastructure. "
|
|
65
|
+
"Add docs here for testing patterns specific to erk."
|
|
66
|
+
),
|
|
67
|
+
"textual": (
|
|
68
|
+
"Explore when working with Textual framework. Add docs here for Textual-specific patterns."
|
|
69
|
+
),
|
|
70
|
+
"tui": (
|
|
71
|
+
"Explore when working on the erk TUI application. "
|
|
72
|
+
"Add docs here for TUI feature implementation."
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Banner for auto-generated files
|
|
77
|
+
GENERATED_FILE_BANNER = """<!-- AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY -->
|
|
78
|
+
<!-- Edit source frontmatter, then run 'erk docs sync' to regenerate. -->
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_frontmatter(content: str) -> tuple[dict[str, object] | None, str | None]:
|
|
84
|
+
"""Parse YAML frontmatter from markdown content.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
content: The markdown file content.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Tuple of (parsed_dict, error_message). If parsing succeeds,
|
|
91
|
+
error_message is None. If parsing fails, parsed_dict is None.
|
|
92
|
+
"""
|
|
93
|
+
match = FRONTMATTER_PATTERN.match(content)
|
|
94
|
+
if match is None:
|
|
95
|
+
return None, "No frontmatter found"
|
|
96
|
+
|
|
97
|
+
frontmatter_text = match.group(1)
|
|
98
|
+
try:
|
|
99
|
+
parsed = yaml.safe_load(frontmatter_text)
|
|
100
|
+
if not isinstance(parsed, dict):
|
|
101
|
+
return None, "Frontmatter is not a valid YAML mapping"
|
|
102
|
+
return parsed, None
|
|
103
|
+
except yaml.YAMLError as e:
|
|
104
|
+
return None, f"Invalid YAML: {e}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_tripwires(
|
|
108
|
+
tripwires_data: object,
|
|
109
|
+
) -> tuple[list[Tripwire], list[str]]:
|
|
110
|
+
"""Validate the tripwires field from frontmatter.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
tripwires_data: Raw tripwires data from YAML.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Tuple of (tripwires, errors). If validation succeeds,
|
|
117
|
+
errors is empty and tripwires contains parsed Tripwire objects.
|
|
118
|
+
"""
|
|
119
|
+
errors: list[str] = []
|
|
120
|
+
tripwires: list[Tripwire] = []
|
|
121
|
+
|
|
122
|
+
if not isinstance(tripwires_data, list):
|
|
123
|
+
errors.append("Field 'tripwires' must be a list")
|
|
124
|
+
return [], errors
|
|
125
|
+
|
|
126
|
+
for i, item in enumerate(tripwires_data):
|
|
127
|
+
if not isinstance(item, dict):
|
|
128
|
+
errors.append(f"Field 'tripwires[{i}]' must be an object")
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Type narrowing: item is dict after isinstance check, cast for .get() typing
|
|
132
|
+
item_dict = cast(dict[str, Any], item)
|
|
133
|
+
action = item_dict.get("action")
|
|
134
|
+
warning = item_dict.get("warning")
|
|
135
|
+
|
|
136
|
+
if not action:
|
|
137
|
+
errors.append(f"Field 'tripwires[{i}].action' is required")
|
|
138
|
+
elif not isinstance(action, str):
|
|
139
|
+
errors.append(f"Field 'tripwires[{i}].action' must be a string")
|
|
140
|
+
|
|
141
|
+
if not warning:
|
|
142
|
+
errors.append(f"Field 'tripwires[{i}].warning' is required")
|
|
143
|
+
elif not isinstance(warning, str):
|
|
144
|
+
errors.append(f"Field 'tripwires[{i}].warning' must be a string")
|
|
145
|
+
|
|
146
|
+
# Only create Tripwire if both fields are valid strings
|
|
147
|
+
if isinstance(action, str) and action and isinstance(warning, str) and warning:
|
|
148
|
+
tripwires.append(Tripwire(action=action, warning=warning))
|
|
149
|
+
|
|
150
|
+
return tripwires, errors
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def validate_agent_doc_frontmatter(
|
|
154
|
+
data: Mapping[str, object],
|
|
155
|
+
) -> tuple[AgentDocFrontmatter | None, list[str]]:
|
|
156
|
+
"""Validate parsed frontmatter against the schema.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
data: Parsed YAML dictionary.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Tuple of (frontmatter, errors). If validation succeeds,
|
|
163
|
+
errors is empty. If validation fails, frontmatter is None.
|
|
164
|
+
"""
|
|
165
|
+
errors: list[str] = []
|
|
166
|
+
|
|
167
|
+
# Check title
|
|
168
|
+
title = data.get("title")
|
|
169
|
+
if not title:
|
|
170
|
+
errors.append("Missing required field: title")
|
|
171
|
+
elif not isinstance(title, str):
|
|
172
|
+
errors.append("Field 'title' must be a string")
|
|
173
|
+
|
|
174
|
+
# Check read_when
|
|
175
|
+
read_when = data.get("read_when")
|
|
176
|
+
if read_when is None:
|
|
177
|
+
errors.append("Missing required field: read_when")
|
|
178
|
+
elif not isinstance(read_when, list):
|
|
179
|
+
errors.append("Field 'read_when' must be a list")
|
|
180
|
+
elif len(read_when) == 0:
|
|
181
|
+
errors.append("Field 'read_when' must not be empty")
|
|
182
|
+
else:
|
|
183
|
+
for i, item in enumerate(read_when):
|
|
184
|
+
if not isinstance(item, str):
|
|
185
|
+
errors.append(f"Field 'read_when[{i}]' must be a string")
|
|
186
|
+
|
|
187
|
+
# Check tripwires (optional)
|
|
188
|
+
tripwires: list[Tripwire] = []
|
|
189
|
+
tripwires_data = data.get("tripwires")
|
|
190
|
+
if tripwires_data is not None:
|
|
191
|
+
tripwires, tripwire_errors = _validate_tripwires(tripwires_data)
|
|
192
|
+
errors.extend(tripwire_errors)
|
|
193
|
+
|
|
194
|
+
if errors:
|
|
195
|
+
return None, errors
|
|
196
|
+
|
|
197
|
+
# At this point, validation has ensured title is str and read_when is list[str]
|
|
198
|
+
assert isinstance(title, str)
|
|
199
|
+
assert isinstance(read_when, list) and all(isinstance(x, str) for x in read_when)
|
|
200
|
+
return AgentDocFrontmatter(
|
|
201
|
+
title=title,
|
|
202
|
+
read_when=cast(list[str], read_when),
|
|
203
|
+
tripwires=tripwires,
|
|
204
|
+
), []
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def validate_agent_doc_file(file_path: Path, agent_docs_root: Path) -> AgentDocValidationResult:
|
|
208
|
+
"""Validate a single agent documentation file.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
file_path: Absolute path to the markdown file.
|
|
212
|
+
agent_docs_root: Path to the .erk/docs/agent directory.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Validation result with any errors found.
|
|
216
|
+
"""
|
|
217
|
+
rel_path = str(file_path.relative_to(agent_docs_root))
|
|
218
|
+
|
|
219
|
+
if not file_path.exists():
|
|
220
|
+
return AgentDocValidationResult(
|
|
221
|
+
file_path=rel_path,
|
|
222
|
+
frontmatter=None,
|
|
223
|
+
errors=("File does not exist",),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
content = file_path.read_text(encoding="utf-8")
|
|
228
|
+
except Exception as e:
|
|
229
|
+
return AgentDocValidationResult(
|
|
230
|
+
file_path=rel_path,
|
|
231
|
+
frontmatter=None,
|
|
232
|
+
errors=(f"Cannot read file: {e}",),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
parsed, parse_error = parse_frontmatter(content)
|
|
236
|
+
if parse_error is not None:
|
|
237
|
+
return AgentDocValidationResult(
|
|
238
|
+
file_path=rel_path,
|
|
239
|
+
frontmatter=None,
|
|
240
|
+
errors=(parse_error,),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# parse_error is None means parsed is not None
|
|
244
|
+
assert parsed is not None
|
|
245
|
+
frontmatter, validation_errors = validate_agent_doc_frontmatter(parsed)
|
|
246
|
+
return AgentDocValidationResult(
|
|
247
|
+
file_path=rel_path,
|
|
248
|
+
frontmatter=frontmatter,
|
|
249
|
+
errors=tuple(validation_errors),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def discover_agent_docs(agent_docs_root: Path) -> list[Path]:
|
|
254
|
+
"""Discover all markdown files in the agent docs directory.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
agent_docs_root: Path to the .erk/docs/agent directory.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
List of paths to markdown files, sorted alphabetically.
|
|
261
|
+
"""
|
|
262
|
+
if not agent_docs_root.exists():
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
files: list[Path] = []
|
|
266
|
+
for md_file in agent_docs_root.rglob("*.md"):
|
|
267
|
+
# Skip index files (they are auto-generated)
|
|
268
|
+
if md_file.name == "index.md":
|
|
269
|
+
continue
|
|
270
|
+
files.append(md_file)
|
|
271
|
+
|
|
272
|
+
return sorted(files)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def validate_agent_docs(project_root: Path) -> list[AgentDocValidationResult]:
|
|
276
|
+
"""Validate all agent documentation files in a project.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
project_root: Path to the project root.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
List of validation results for each file.
|
|
283
|
+
"""
|
|
284
|
+
agent_docs_root = project_root / AGENT_DOCS_DIR
|
|
285
|
+
if not agent_docs_root.exists():
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
files = discover_agent_docs(agent_docs_root)
|
|
289
|
+
results: list[AgentDocValidationResult] = []
|
|
290
|
+
|
|
291
|
+
for file_path in files:
|
|
292
|
+
result = validate_agent_doc_file(file_path, agent_docs_root)
|
|
293
|
+
results.append(result)
|
|
294
|
+
|
|
295
|
+
return results
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def collect_tripwires(project_root: Path) -> list[CollectedTripwire]:
|
|
299
|
+
"""Collect all tripwires from agent documentation frontmatter.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
project_root: Path to the project root.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
List of collected tripwires with their source documentation info.
|
|
306
|
+
"""
|
|
307
|
+
agent_docs_root = project_root / AGENT_DOCS_DIR
|
|
308
|
+
if not agent_docs_root.exists():
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
files = discover_agent_docs(agent_docs_root)
|
|
312
|
+
tripwires: list[CollectedTripwire] = []
|
|
313
|
+
|
|
314
|
+
for file_path in files:
|
|
315
|
+
result = validate_agent_doc_file(file_path, agent_docs_root)
|
|
316
|
+
if not result.is_valid or result.frontmatter is None:
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
rel_path = str(file_path.relative_to(agent_docs_root))
|
|
320
|
+
|
|
321
|
+
for tripwire in result.frontmatter.tripwires:
|
|
322
|
+
tripwires.append(
|
|
323
|
+
CollectedTripwire(
|
|
324
|
+
action=tripwire.action,
|
|
325
|
+
warning=tripwire.warning,
|
|
326
|
+
doc_path=rel_path,
|
|
327
|
+
doc_title=result.frontmatter.title,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return tripwires
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def generate_tripwires_doc(tripwires: list[CollectedTripwire]) -> str:
|
|
335
|
+
"""Generate content for the tripwires.md file.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
tripwires: List of collected tripwires.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Generated markdown content for the tripwires reference document.
|
|
342
|
+
"""
|
|
343
|
+
# Note: Banner goes AFTER frontmatter so YAML parsing works correctly
|
|
344
|
+
lines = [
|
|
345
|
+
"---",
|
|
346
|
+
"title: Generated Tripwires",
|
|
347
|
+
"read_when:",
|
|
348
|
+
' - "checking tripwire rules"',
|
|
349
|
+
"---",
|
|
350
|
+
"",
|
|
351
|
+
GENERATED_FILE_BANNER.rstrip(),
|
|
352
|
+
"",
|
|
353
|
+
"# Tripwires",
|
|
354
|
+
"",
|
|
355
|
+
"Action-triggered rules that fire when you're about to perform specific actions.",
|
|
356
|
+
"",
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
if not tripwires:
|
|
360
|
+
lines.append("*No tripwires defined.*")
|
|
361
|
+
lines.append("")
|
|
362
|
+
return "\n".join(lines)
|
|
363
|
+
|
|
364
|
+
for tripwire in tripwires:
|
|
365
|
+
# Format: **CRITICAL: Before [action]** → Read [doc-path] first. [warning]
|
|
366
|
+
lines.append(
|
|
367
|
+
f"**CRITICAL: Before {tripwire.action}** → "
|
|
368
|
+
f"Read [{tripwire.doc_title}]({tripwire.doc_path}) first. "
|
|
369
|
+
f"{tripwire.warning}"
|
|
370
|
+
)
|
|
371
|
+
lines.append("")
|
|
372
|
+
|
|
373
|
+
return "\n".join(lines)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def collect_valid_docs(project_root: Path) -> tuple[list[DocInfo], list[CategoryInfo], int]:
|
|
377
|
+
"""Collect all valid documentation files organized by category.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
project_root: Path to the project root.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Tuple of (uncategorized_docs, categories, invalid_count).
|
|
384
|
+
"""
|
|
385
|
+
agent_docs_root = project_root / AGENT_DOCS_DIR
|
|
386
|
+
if not agent_docs_root.exists():
|
|
387
|
+
return [], [], 0
|
|
388
|
+
|
|
389
|
+
files = discover_agent_docs(agent_docs_root)
|
|
390
|
+
uncategorized: list[DocInfo] = []
|
|
391
|
+
categories: dict[str, list[DocInfo]] = {}
|
|
392
|
+
invalid_count = 0
|
|
393
|
+
|
|
394
|
+
for file_path in files:
|
|
395
|
+
result = validate_agent_doc_file(file_path, agent_docs_root)
|
|
396
|
+
if not result.is_valid or result.frontmatter is None:
|
|
397
|
+
invalid_count += 1
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
rel_path = file_path.relative_to(agent_docs_root)
|
|
401
|
+
doc_info = DocInfo(
|
|
402
|
+
rel_path=str(rel_path),
|
|
403
|
+
frontmatter=result.frontmatter,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Check if in subdirectory (category)
|
|
407
|
+
if len(rel_path.parts) > 1:
|
|
408
|
+
category = rel_path.parts[0]
|
|
409
|
+
if category not in categories:
|
|
410
|
+
categories[category] = []
|
|
411
|
+
categories[category].append(doc_info)
|
|
412
|
+
else:
|
|
413
|
+
uncategorized.append(doc_info)
|
|
414
|
+
|
|
415
|
+
# Convert to CategoryInfo list
|
|
416
|
+
category_list = [
|
|
417
|
+
CategoryInfo(
|
|
418
|
+
name=name,
|
|
419
|
+
docs=tuple(sorted(docs, key=lambda d: d.rel_path)),
|
|
420
|
+
)
|
|
421
|
+
for name, docs in sorted(categories.items())
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
return sorted(uncategorized, key=lambda d: d.rel_path), category_list, invalid_count
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def generate_root_index(
|
|
428
|
+
uncategorized: list[DocInfo],
|
|
429
|
+
categories: list[CategoryInfo],
|
|
430
|
+
) -> str:
|
|
431
|
+
"""Generate content for the root index.md file.
|
|
432
|
+
|
|
433
|
+
Uses bullet list format instead of tables to avoid merge conflicts.
|
|
434
|
+
Each entry is independent - no separator rows that span all entries.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
uncategorized: Docs at the root level.
|
|
438
|
+
categories: List of category directories with their docs.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Generated markdown content.
|
|
442
|
+
"""
|
|
443
|
+
lines = [GENERATED_FILE_BANNER.rstrip(), "", "# Agent Documentation", ""]
|
|
444
|
+
|
|
445
|
+
if categories:
|
|
446
|
+
lines.append("## Categories")
|
|
447
|
+
lines.append("")
|
|
448
|
+
for category in categories:
|
|
449
|
+
description = CATEGORY_DESCRIPTIONS.get(category.name)
|
|
450
|
+
if description:
|
|
451
|
+
lines.append(f"- [{category.name}/]({category.name}/) — {description}")
|
|
452
|
+
else:
|
|
453
|
+
lines.append(f"- [{category.name}/]({category.name}/)")
|
|
454
|
+
lines.append("")
|
|
455
|
+
|
|
456
|
+
if uncategorized:
|
|
457
|
+
lines.append("## Uncategorized")
|
|
458
|
+
lines.append("")
|
|
459
|
+
for doc in uncategorized:
|
|
460
|
+
read_when = ", ".join(doc.frontmatter.read_when)
|
|
461
|
+
lines.append(f"- **[{doc.rel_path}]({doc.rel_path})** — {read_when}")
|
|
462
|
+
lines.append("")
|
|
463
|
+
|
|
464
|
+
if not categories and not uncategorized:
|
|
465
|
+
lines.append("*No documentation files found.*")
|
|
466
|
+
lines.append("")
|
|
467
|
+
|
|
468
|
+
return "\n".join(lines)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def generate_category_index(category: CategoryInfo) -> str:
|
|
472
|
+
"""Generate content for a category's index.md file.
|
|
473
|
+
|
|
474
|
+
Uses bullet list format instead of tables to avoid merge conflicts.
|
|
475
|
+
Each entry is independent - no separator rows that span all entries.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
category: Category information with docs.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Generated markdown content.
|
|
482
|
+
"""
|
|
483
|
+
# Title case the category name
|
|
484
|
+
title = category.name.replace("-", " ").replace("_", " ").title()
|
|
485
|
+
|
|
486
|
+
lines = [GENERATED_FILE_BANNER.rstrip(), "", f"# {title} Documentation", ""]
|
|
487
|
+
|
|
488
|
+
for doc in category.docs:
|
|
489
|
+
# Use just the filename for relative links within category
|
|
490
|
+
filename = Path(doc.rel_path).name
|
|
491
|
+
read_when = ", ".join(doc.frontmatter.read_when)
|
|
492
|
+
lines.append(f"- **[{filename}]({filename})** — {read_when}")
|
|
493
|
+
|
|
494
|
+
lines.append("")
|
|
495
|
+
return "\n".join(lines)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _format_with_prettier(content: str, file_path: Path) -> str:
|
|
499
|
+
"""Format markdown content with prettier.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
content: The markdown content to format.
|
|
503
|
+
file_path: Path to use for prettier's parser detection.
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
Formatted content.
|
|
507
|
+
"""
|
|
508
|
+
result = run_subprocess_with_context(
|
|
509
|
+
["prettier", "--stdin-filepath", str(file_path)],
|
|
510
|
+
operation_context="format markdown with prettier",
|
|
511
|
+
input=content,
|
|
512
|
+
)
|
|
513
|
+
return result.stdout
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _update_index_file(
|
|
517
|
+
index_path: Path,
|
|
518
|
+
content: str,
|
|
519
|
+
created: list[str],
|
|
520
|
+
updated: list[str],
|
|
521
|
+
unchanged: list[str],
|
|
522
|
+
dry_run: bool,
|
|
523
|
+
) -> None:
|
|
524
|
+
"""Update an index file if content changed.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
index_path: Path to the index file.
|
|
528
|
+
content: New content to write.
|
|
529
|
+
created: List to append if file was created.
|
|
530
|
+
updated: List to append if file was updated.
|
|
531
|
+
unchanged: List to append if file was unchanged.
|
|
532
|
+
dry_run: If True, don't actually write.
|
|
533
|
+
"""
|
|
534
|
+
rel_path = str(index_path.relative_to(index_path.parent.parent.parent))
|
|
535
|
+
|
|
536
|
+
# Format content with prettier before comparing or writing
|
|
537
|
+
formatted_content = _format_with_prettier(content, index_path)
|
|
538
|
+
|
|
539
|
+
if not index_path.exists():
|
|
540
|
+
if not dry_run:
|
|
541
|
+
index_path.write_text(formatted_content, encoding="utf-8")
|
|
542
|
+
created.append(rel_path)
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
existing = index_path.read_text(encoding="utf-8")
|
|
546
|
+
if existing == formatted_content:
|
|
547
|
+
unchanged.append(rel_path)
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
if not dry_run:
|
|
551
|
+
index_path.write_text(formatted_content, encoding="utf-8")
|
|
552
|
+
updated.append(rel_path)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _update_generated_file(
|
|
556
|
+
file_path: Path,
|
|
557
|
+
content: str,
|
|
558
|
+
created: list[str],
|
|
559
|
+
updated: list[str],
|
|
560
|
+
unchanged: list[str],
|
|
561
|
+
dry_run: bool,
|
|
562
|
+
agent_docs_root: Path,
|
|
563
|
+
) -> None:
|
|
564
|
+
"""Update a generated file if content changed.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
file_path: Path to the generated file.
|
|
568
|
+
content: New content to write.
|
|
569
|
+
created: List to append if file was created.
|
|
570
|
+
updated: List to append if file was updated.
|
|
571
|
+
unchanged: List to append if file was unchanged.
|
|
572
|
+
dry_run: If True, don't actually write.
|
|
573
|
+
agent_docs_root: Path to .erk/docs/agent/ for relative path calculation.
|
|
574
|
+
"""
|
|
575
|
+
rel_path = str(file_path.relative_to(agent_docs_root.parent.parent))
|
|
576
|
+
|
|
577
|
+
# Format content with prettier before comparing or writing
|
|
578
|
+
formatted_content = _format_with_prettier(content, file_path)
|
|
579
|
+
|
|
580
|
+
if not file_path.exists():
|
|
581
|
+
if not dry_run:
|
|
582
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
583
|
+
file_path.write_text(formatted_content, encoding="utf-8")
|
|
584
|
+
created.append(rel_path)
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
existing = file_path.read_text(encoding="utf-8")
|
|
588
|
+
if existing == formatted_content:
|
|
589
|
+
unchanged.append(rel_path)
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
if not dry_run:
|
|
593
|
+
file_path.write_text(formatted_content, encoding="utf-8")
|
|
594
|
+
updated.append(rel_path)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def sync_agent_docs(project_root: Path, *, dry_run: bool) -> SyncResult:
|
|
598
|
+
"""Sync agent documentation index files from frontmatter.
|
|
599
|
+
|
|
600
|
+
Generates index.md files for the root .erk/docs/agent/ directory and
|
|
601
|
+
each subdirectory (category) that contains 2+ docs. Also generates
|
|
602
|
+
the .erk/docs/agent/tripwires.md file from tripwire definitions.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
project_root: Path to the project root.
|
|
606
|
+
dry_run: If True, don't write files, just report what would change.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
SyncResult with lists of created, updated, and unchanged files.
|
|
610
|
+
"""
|
|
611
|
+
agent_docs_root = project_root / AGENT_DOCS_DIR
|
|
612
|
+
if not agent_docs_root.exists():
|
|
613
|
+
return SyncResult(
|
|
614
|
+
created=(),
|
|
615
|
+
updated=(),
|
|
616
|
+
unchanged=(),
|
|
617
|
+
skipped_invalid=0,
|
|
618
|
+
tripwires_count=0,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
uncategorized, categories, invalid_count = collect_valid_docs(project_root)
|
|
622
|
+
|
|
623
|
+
created: list[str] = []
|
|
624
|
+
updated: list[str] = []
|
|
625
|
+
unchanged: list[str] = []
|
|
626
|
+
|
|
627
|
+
# Generate root index
|
|
628
|
+
root_index_path = agent_docs_root / "index.md"
|
|
629
|
+
root_content = generate_root_index(uncategorized, categories)
|
|
630
|
+
_update_index_file(root_index_path, root_content, created, updated, unchanged, dry_run)
|
|
631
|
+
|
|
632
|
+
# Generate category indexes (only for categories with 2+ docs)
|
|
633
|
+
for category in categories:
|
|
634
|
+
if len(category.docs) < 2:
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
category_index_path = agent_docs_root / category.name / "index.md"
|
|
638
|
+
category_content = generate_category_index(category)
|
|
639
|
+
_update_index_file(
|
|
640
|
+
category_index_path, category_content, created, updated, unchanged, dry_run
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
# Collect and generate tripwires
|
|
644
|
+
tripwires = collect_tripwires(project_root)
|
|
645
|
+
tripwires_count = len(tripwires)
|
|
646
|
+
|
|
647
|
+
if tripwires:
|
|
648
|
+
tripwires_path = agent_docs_root / "tripwires.md"
|
|
649
|
+
tripwires_content = generate_tripwires_doc(tripwires)
|
|
650
|
+
_update_generated_file(
|
|
651
|
+
tripwires_path,
|
|
652
|
+
tripwires_content,
|
|
653
|
+
created,
|
|
654
|
+
updated,
|
|
655
|
+
unchanged,
|
|
656
|
+
dry_run,
|
|
657
|
+
agent_docs_root,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
return SyncResult(
|
|
661
|
+
created=tuple(created),
|
|
662
|
+
updated=tuple(updated),
|
|
663
|
+
unchanged=tuple(unchanged),
|
|
664
|
+
skipped_invalid=invalid_count,
|
|
665
|
+
tripwires_count=tripwires_count,
|
|
666
|
+
)
|