1
0

workflow_phase.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  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 the compact Phase Index summary from workflow.md.
  45. SessionStart and no-step phase context use this small summary as their
  46. orientation payload. Detailed Phase 1/2/3 instructions are loaded with
  47. ``get_step`` on demand. ``[workflow-state:STATUS]`` tag blocks are
  48. consumed by the per-turn hook, so they're stripped from this output.
  49. """
  50. text = _read_workflow()
  51. lines = text.splitlines()
  52. start: int | None = None
  53. end: int | None = None
  54. for i, line in enumerate(lines):
  55. stripped = line.strip()
  56. if start is None and stripped == _PHASE_INDEX_HEADING:
  57. start = i
  58. continue
  59. if start is not None and stripped == "## Phase 1: Plan":
  60. end = i
  61. break
  62. if start is None:
  63. return ""
  64. if end is None:
  65. end = len(lines)
  66. section = "\n".join(lines[start:end]).rstrip()
  67. # Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since
  68. # they're injected separately by inject-workflow-state.py per-turn.
  69. import re as _re
  70. tag_re = _re.compile(
  71. r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?",
  72. _re.DOTALL,
  73. )
  74. return tag_re.sub("", section).rstrip() + "\n"
  75. def get_step(step_id: str) -> str:
  76. """Return the `#### X.X` section matching step_id (header + body).
  77. Body ends at the next `####` or `---` or `##` heading (whichever comes first).
  78. """
  79. text = _read_workflow()
  80. lines = text.splitlines()
  81. start: int | None = None
  82. for i, line in enumerate(lines):
  83. m = _STEP_HEADING_RE.match(line)
  84. if m and m.group(1) == step_id:
  85. start = i
  86. break
  87. if start is None:
  88. return ""
  89. end: int = len(lines)
  90. for j in range(start + 1, len(lines)):
  91. line = lines[j]
  92. if line.startswith("#### "):
  93. end = j
  94. break
  95. if line.startswith("## "):
  96. end = j
  97. break
  98. # Horizontal rule at column 0
  99. if line.strip() == "---":
  100. end = j
  101. break
  102. return "\n".join(lines[start:end]).rstrip() + "\n"
  103. def _platform_matches(platform: str, block_names: list[str]) -> bool:
  104. """Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'."""
  105. needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "")
  106. for name in block_names:
  107. hay = name.lower().replace("-", "").replace("_", "").replace(" ", "")
  108. if needle == hay:
  109. return True
  110. return False
  111. def resolve_effective_platform(platform: str, config: dict) -> str:
  112. """Map ``codex`` to a dispatch-mode-namespaced virtual platform name.
  113. When ``--platform codex`` is passed, return ``"codex-inline"`` (default)
  114. or ``"codex-sub-agent"`` based on ``.trellis/config.yaml`` ``codex.dispatch_mode``.
  115. ``filter_platform`` then surfaces blocks whose marker lists include the
  116. namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo,
  117. Antigravity, Devin]``).
  118. Default is ``inline`` because Codex sub-agents run with ``fork_turns="none"``
  119. isolation and can't inherit the parent session's task context — inline
  120. keeps the main agent in charge so context isn't lost. Invalid / missing
  121. values also fall back to inline.
  122. Other platforms are returned unchanged.
  123. """
  124. if platform == "codex":
  125. mode = "inline"
  126. codex_cfg = config.get("codex") if isinstance(config, dict) else None
  127. if isinstance(codex_cfg, dict):
  128. cfg_mode = codex_cfg.get("dispatch_mode")
  129. if cfg_mode in ("inline", "sub-agent"):
  130. mode = cfg_mode
  131. return f"codex-{mode}"
  132. return platform
  133. def filter_platform(content: str, platform: str) -> str:
  134. """Keep lines outside any `[...]` block + lines inside blocks that include platform.
  135. Marker lines themselves are dropped from the output.
  136. """
  137. lines = content.splitlines()
  138. out: list[str] = []
  139. in_block = False
  140. keep_block = False
  141. for line in lines:
  142. marker = _parse_marker(line)
  143. if marker is not None:
  144. is_closing, names = marker
  145. if not is_closing:
  146. in_block = True
  147. keep_block = _platform_matches(platform, names)
  148. else:
  149. in_block = False
  150. keep_block = False
  151. continue # drop the marker line itself
  152. if in_block:
  153. if keep_block:
  154. out.append(line)
  155. continue
  156. out.append(line)
  157. # Collapse runs of 3+ blank lines that may arise from dropped markers
  158. collapsed: list[str] = []
  159. blank_run = 0
  160. for line in out:
  161. if line.strip() == "":
  162. blank_run += 1
  163. if blank_run <= 2:
  164. collapsed.append(line)
  165. else:
  166. blank_run = 0
  167. collapsed.append(line)
  168. return "\n".join(collapsed).rstrip() + "\n"