workflow_phase.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Workflow Phase Extraction.
  5. Extracts step-level content from .trellis/workflow.md and optionally filters
  6. platform-specific blocks.
  7. Platform marker syntax in workflow.md:
  8. [Claude Code, Cursor, ...]
  9. agent-capable content
  10. [/Claude Code, Cursor, ...]
  11. Provides:
  12. get_phase_index - Extract the Phase Index section (no --step)
  13. get_step - Extract a single step (#### X.X) section
  14. filter_platform - Strip platform blocks that don't include the given name
  15. """
  16. from __future__ import annotations
  17. import re
  18. from .paths import DIR_WORKFLOW, get_repo_root
  19. def _workflow_md_path():
  20. return get_repo_root() / DIR_WORKFLOW / "workflow.md"
  21. # Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
  22. _MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
  23. # Step heading: "#### 1.0 Title" or "#### 1.0 ..."
  24. _STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$")
  25. # Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs.
  26. _PHASE_INDEX_HEADING = "## Phase Index"
  27. def _read_workflow() -> str:
  28. path = _workflow_md_path()
  29. if not path.exists():
  30. raise FileNotFoundError(f"workflow.md not found: {path}")
  31. return path.read_text(encoding="utf-8")
  32. def _parse_marker(line: str) -> tuple[bool, list[str]] | None:
  33. """Parse a platform marker line.
  34. Returns:
  35. (is_closing, [platform_names]) if line is a marker, else None.
  36. """
  37. m = _MARKER_RE.match(line)
  38. if not m:
  39. return None
  40. is_closing = m.group(1) == "/"
  41. names = [p.strip() for p in m.group(2).split(",") if p.strip()]
  42. return is_closing, names
  43. def get_phase_index() -> str:
  44. """Return Phase Index + Phase 1/2/3 step bodies from workflow.md.
  45. Matches what the SessionStart hook injects into the `<workflow>` block:
  46. starts at `## Phase Index`, continues through `## Phase 1: Plan`,
  47. `## Phase 2: Execute`, `## Phase 3: Finish`, stops at
  48. `## Customizing Trellis (for forks)` (the docs-for-forks footer).
  49. `[workflow-state:STATUS]` tag blocks (now embedded in Phase Index since
  50. v0.5.0-rc.0) are consumed by the UserPromptSubmit hook so they're
  51. stripped from this output.
  52. """
  53. text = _read_workflow()
  54. lines = text.splitlines()
  55. start: int | None = None
  56. end: int | None = None
  57. for i, line in enumerate(lines):
  58. stripped = line.strip()
  59. if start is None and stripped == _PHASE_INDEX_HEADING:
  60. start = i
  61. continue
  62. if start is not None and stripped == "## Customizing Trellis (for forks)":
  63. end = i
  64. break
  65. if start is None:
  66. return ""
  67. if end is None:
  68. end = len(lines)
  69. section = "\n".join(lines[start:end]).rstrip()
  70. # Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since
  71. # they're injected separately by inject-workflow-state.py per-turn.
  72. import re as _re
  73. tag_re = _re.compile(
  74. r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?",
  75. _re.DOTALL,
  76. )
  77. return tag_re.sub("", section).rstrip() + "\n"
  78. def get_step(step_id: str) -> str:
  79. """Return the `#### X.X` section matching step_id (header + body).
  80. Body ends at the next `####` or `---` or `##` heading (whichever comes first).
  81. """
  82. text = _read_workflow()
  83. lines = text.splitlines()
  84. start: int | None = None
  85. for i, line in enumerate(lines):
  86. m = _STEP_HEADING_RE.match(line)
  87. if m and m.group(1) == step_id:
  88. start = i
  89. break
  90. if start is None:
  91. return ""
  92. end: int = len(lines)
  93. for j in range(start + 1, len(lines)):
  94. line = lines[j]
  95. if line.startswith("#### "):
  96. end = j
  97. break
  98. if line.startswith("## "):
  99. end = j
  100. break
  101. # Horizontal rule at column 0
  102. if line.strip() == "---":
  103. end = j
  104. break
  105. return "\n".join(lines[start:end]).rstrip() + "\n"
  106. def _platform_matches(platform: str, block_names: list[str]) -> bool:
  107. """Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'."""
  108. needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "")
  109. for name in block_names:
  110. hay = name.lower().replace("-", "").replace("_", "").replace(" ", "")
  111. if needle == hay:
  112. return True
  113. return False
  114. def resolve_effective_platform(platform: str, config: dict) -> str:
  115. """Map ``codex`` to a dispatch-mode-namespaced virtual platform name.
  116. When ``--platform codex`` is passed, return ``"codex-inline"`` (default)
  117. or ``"codex-sub-agent"`` based on ``.trellis/config.yaml`` ``codex.dispatch_mode``.
  118. ``filter_platform`` then surfaces blocks whose marker lists include the
  119. namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo,
  120. Antigravity, Windsurf]``).
  121. Default is ``inline`` because Codex sub-agents run with ``fork_turns="none"``
  122. isolation and can't inherit the parent session's task context — inline
  123. keeps the main agent in charge so context isn't lost. Invalid / missing
  124. values also fall back to inline.
  125. Other platforms are returned unchanged.
  126. """
  127. if platform == "codex":
  128. mode = "inline"
  129. codex_cfg = config.get("codex") if isinstance(config, dict) else None
  130. if isinstance(codex_cfg, dict):
  131. cfg_mode = codex_cfg.get("dispatch_mode")
  132. if cfg_mode in ("inline", "sub-agent"):
  133. mode = cfg_mode
  134. return f"codex-{mode}"
  135. return platform
  136. def filter_platform(content: str, platform: str) -> str:
  137. """Keep lines outside any `[...]` block + lines inside blocks that include platform.
  138. Marker lines themselves are dropped from the output.
  139. """
  140. lines = content.splitlines()
  141. out: list[str] = []
  142. in_block = False
  143. keep_block = False
  144. for line in lines:
  145. marker = _parse_marker(line)
  146. if marker is not None:
  147. is_closing, names = marker
  148. if not is_closing:
  149. in_block = True
  150. keep_block = _platform_matches(platform, names)
  151. else:
  152. in_block = False
  153. keep_block = False
  154. continue # drop the marker line itself
  155. if in_block:
  156. if keep_block:
  157. out.append(line)
  158. continue
  159. out.append(line)
  160. # Collapse runs of 3+ blank lines that may arise from dropped markers
  161. collapsed: list[str] = []
  162. blank_run = 0
  163. for line in out:
  164. if line.strip() == "":
  165. blank_run += 1
  166. if blank_run <= 2:
  167. collapsed.append(line)
  168. else:
  169. blank_run = 0
  170. collapsed.append(line)
  171. return "\n".join(collapsed).rstrip() + "\n"