Skip to content

Commit 74ff3f5

Browse files
Merge pull request #273 from PurdueRCAC/feature/agent-factory
[harness] Add the RCAC-Docs documentation factory (spec-driven authoring lifecycle)
2 parents 4f3e357 + 81bc59f commit 74ff3f5

27 files changed

Lines changed: 2960 additions & 239 deletions

.agents/factory/bin/_fsm.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Shared helpers for the RCAC-Docs documentation-factory FSM scripts.
2+
3+
The finite-state machine for a documentation job (a `feature`/`fix`/`refactor`)
4+
lives in the YAML frontmatter of its ``spec/<slug>/TECH.md`` roadmap. These
5+
helpers read, validate, mutate, and re-serialize that frontmatter so the
6+
*scripts* (not the model) own the fragile YAML arithmetic — model in-context
7+
YAML editing is the primary FSM-corruption risk (see
8+
``.agents/factory/methodology.md``).
9+
10+
Requires PyYAML, which is a declared project dependency (``requirements.txt``)
11+
and lives in the project virtual environment — NOT the system interpreter. Run
12+
these scripts with the project env active, e.g. from the repo root::
13+
14+
source .venv/bin/activate # or: conda activate rcac-docs
15+
python3 .agents/factory/bin/<script>.py ...
16+
17+
Adapted from the HyperShell software factory (`.agents/factory/bin`).
18+
"""
19+
from __future__ import annotations
20+
21+
# Standard libs
22+
import datetime
23+
from typing import Any
24+
25+
# External libs
26+
try:
27+
import yaml
28+
except ModuleNotFoundError as exc: # pragma: no cover - environment guard
29+
raise SystemExit(
30+
"PyYAML is required but was not found. Activate the project environment "
31+
"first (e.g. `source .venv/bin/activate` or `conda activate rcac-docs`), "
32+
"then run `python3 .agents/factory/bin/<script>.py` from the repo root."
33+
) from exc
34+
35+
36+
# Public interface
37+
__all__ = [
38+
"FSMError",
39+
"REQUIRED_TOP",
40+
"PHASE_STATUSES",
41+
"TOP_STATUSES",
42+
"FIELD_ORDER",
43+
"PHASE_FIELD_ORDER",
44+
"split_frontmatter",
45+
"dump_document",
46+
"validate",
47+
"compute_next",
48+
"today",
49+
]
50+
51+
52+
REQUIRED_TOP = ["slug", "kind", "appetite", "status", "branch", "base", "current_phase", "phases"]
53+
PHASE_STATUSES = {"pending", "in_progress", "done", "blocked"}
54+
TOP_STATUSES = {"planned", "in_progress", "blocked", "in_review", "done"}
55+
56+
# Canonical key order for deterministic re-serialization (keys not listed keep
57+
# their existing relative order, appended after these).
58+
FIELD_ORDER = [
59+
"slug", "title", "kind", "appetite", "status", "branch", "base",
60+
"current_phase", "last_updated", "phases", "review",
61+
]
62+
PHASE_FIELD_ORDER = [
63+
"id", "name", "status", "satisfies", "depends_on",
64+
"parallel", "hammerable", "hill", "verify",
65+
]
66+
67+
68+
class FSMError(Exception):
69+
"""Raised on a malformed or invalid TECH.md frontmatter."""
70+
71+
72+
def today() -> str:
73+
"""Return today's date as an ISO-8601 string (local)."""
74+
return datetime.date.today().isoformat()
75+
76+
77+
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
78+
"""Split a markdown document into (frontmatter dict, body string).
79+
80+
The document must open with a ``---`` fence, contain a YAML block, and close
81+
the block with a line that is exactly ``---``. Everything after is the body.
82+
"""
83+
if not text.startswith("---"):
84+
raise FSMError("TECH.md must begin with a '---' YAML frontmatter fence.")
85+
lines = text.splitlines(keepends=True)
86+
# lines[0] is the opening fence; find the closing fence.
87+
for i in range(1, len(lines)):
88+
if lines[i].rstrip("\n") == "---":
89+
fm_text = "".join(lines[1:i])
90+
body = "".join(lines[i + 1:])
91+
break
92+
else:
93+
raise FSMError("Unterminated frontmatter: no closing '---' fence found.")
94+
try:
95+
data = yaml.safe_load(fm_text)
96+
except yaml.YAMLError as exc:
97+
raise FSMError(f"Frontmatter is not valid YAML: {exc}") from exc
98+
if not isinstance(data, dict):
99+
raise FSMError("Frontmatter did not parse to a mapping.")
100+
return data, body
101+
102+
103+
def _ordered(data: dict[str, Any], order: list[str]) -> dict[str, Any]:
104+
"""Return a new dict with keys in `order` first, then any remaining keys."""
105+
out: dict[str, Any] = {}
106+
for key in order:
107+
if key in data:
108+
out[key] = data[key]
109+
for key in data:
110+
if key not in out:
111+
out[key] = data[key]
112+
return out
113+
114+
115+
def dump_document(data: dict[str, Any], body: str) -> str:
116+
"""Re-serialize (frontmatter dict, body) into a full markdown document.
117+
118+
Serialization is canonical and deterministic: top-level and per-phase keys
119+
are emitted in a fixed order; formatting is normalized (inline comments in
120+
the source frontmatter are dropped — enums are documented in the template
121+
and ``methodology.md``). The body is preserved verbatim.
122+
"""
123+
data = _ordered(dict(data), FIELD_ORDER)
124+
phases = data.get("phases")
125+
if isinstance(phases, list):
126+
data["phases"] = [
127+
_ordered(dict(p), PHASE_FIELD_ORDER) if isinstance(p, dict) else p
128+
for p in phases
129+
]
130+
fm = yaml.safe_dump(data, sort_keys=False, allow_unicode=True, default_flow_style=False)
131+
if not body.startswith("\n"):
132+
body = "\n" + body
133+
return f"---\n{fm}---{body}"
134+
135+
136+
def validate(data: dict[str, Any]) -> list[str]:
137+
"""Return a list of human-readable validation errors (empty == valid)."""
138+
errors: list[str] = []
139+
for key in REQUIRED_TOP:
140+
if key not in data:
141+
errors.append(f"missing required top-level key: {key}")
142+
if data.get("status") not in TOP_STATUSES and "status" in data:
143+
errors.append(f"top-level status {data.get('status')!r} not in {sorted(TOP_STATUSES)}")
144+
phases = data.get("phases")
145+
if not isinstance(phases, list) or not phases:
146+
errors.append("phases must be a non-empty list")
147+
return errors
148+
ids: set[str] = set()
149+
for idx, p in enumerate(phases):
150+
if not isinstance(p, dict):
151+
errors.append(f"phase[{idx}] is not a mapping")
152+
continue
153+
pid = p.get("id")
154+
if not pid:
155+
errors.append(f"phase[{idx}] missing id")
156+
continue
157+
if pid in ids:
158+
errors.append(f"duplicate phase id: {pid}")
159+
ids.add(pid)
160+
if p.get("status") not in PHASE_STATUSES:
161+
errors.append(f"phase {pid} status {p.get('status')!r} not in {sorted(PHASE_STATUSES)}")
162+
for p in phases:
163+
if isinstance(p, dict):
164+
for dep in p.get("depends_on") or []:
165+
if dep not in ids:
166+
errors.append(f"phase {p.get('id')} depends_on unknown phase {dep}")
167+
cur = data.get("current_phase")
168+
if cur and cur not in ids and cur not in ("", "done"):
169+
errors.append(f"current_phase {cur!r} is not a known phase id")
170+
return errors
171+
172+
173+
def compute_next(data: dict[str, Any]) -> tuple[dict[str, Any] | None, list[str]]:
174+
"""Compute the next actionable phase from phase statuses (authoritative).
175+
176+
A phase is actionable if its status is pending/in_progress and every phase in
177+
its ``depends_on`` is done. Returns (phase_dict_or_None, warnings). Warnings
178+
flag blocked phases and any drift between the stored ``current_phase`` pointer
179+
and the computed next phase (crash-safety reconciliation signal).
180+
"""
181+
warnings: list[str] = []
182+
phases = data.get("phases") or []
183+
status_by_id = {p.get("id"): p.get("status") for p in phases if isinstance(p, dict)}
184+
185+
for p in phases:
186+
if isinstance(p, dict) and p.get("status") == "blocked":
187+
warnings.append(f"phase {p.get('id')} is blocked")
188+
189+
nxt: dict[str, Any] | None = None
190+
for p in phases:
191+
if not isinstance(p, dict):
192+
continue
193+
if p.get("status") in ("pending", "in_progress"):
194+
deps = p.get("depends_on") or []
195+
unmet = [d for d in deps if status_by_id.get(d) != "done"]
196+
if unmet:
197+
continue
198+
nxt = p
199+
break
200+
201+
stored = data.get("current_phase")
202+
if nxt is not None and stored not in (nxt.get("id"), None, ""):
203+
warnings.append(
204+
f"current_phase pointer {stored!r} != computed next {nxt.get('id')!r} "
205+
"(reconcile before acting)"
206+
)
207+
if nxt is None and stored not in ("", "done", None):
208+
warnings.append(f"no actionable phase but current_phase is {stored!r}")
209+
return nxt, warnings

.agents/factory/bin/next_phase.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env python3
2+
"""Print the next actionable phase of a spec/<slug>/TECH.md FSM as JSON.
3+
4+
The ``docs-draft`` skill runs this at the top of every invocation instead of
5+
parsing the YAML frontmatter itself. The emitted JSON is the ground truth for
6+
"what do I do next"; the model executes, the script computes the transition.
7+
8+
Usage (run from the repo root, with the project env active):
9+
python3 .agents/factory/bin/next_phase.py spec/<slug>/TECH.md
10+
11+
Exit codes: 0 ok · 2 parse/validation error (message on stderr).
12+
"""
13+
from __future__ import annotations
14+
15+
# Standard libs
16+
import json
17+
import sys
18+
from pathlib import Path
19+
20+
# Internal libs (this script's own directory is on sys.path[0], so `import _fsm`
21+
# resolves regardless of the current working directory).
22+
from _fsm import FSMError, compute_next, split_frontmatter, validate
23+
24+
25+
# Public interface
26+
__all__ = ["main"]
27+
28+
29+
def main(argv: list[str]) -> int:
30+
if len(argv) != 1:
31+
print("usage: next_phase.py spec/<slug>/TECH.md", file=sys.stderr)
32+
return 2
33+
path = Path(argv[0])
34+
try:
35+
text = path.read_text(encoding="utf-8")
36+
except OSError as exc:
37+
print(f"cannot read {path}: {exc}", file=sys.stderr)
38+
return 2
39+
try:
40+
data, _body = split_frontmatter(text)
41+
except FSMError as exc:
42+
print(f"{path}: {exc}", file=sys.stderr)
43+
return 2
44+
45+
errors = validate(data)
46+
if errors:
47+
print(f"{path}: invalid TECH.md frontmatter:", file=sys.stderr)
48+
for err in errors:
49+
print(f" - {err}", file=sys.stderr)
50+
return 2
51+
52+
nxt, warnings = compute_next(data)
53+
phases = data.get("phases") or []
54+
counts: dict[str, int] = {s: 0 for s in ("pending", "in_progress", "done", "blocked")}
55+
for p in phases:
56+
if isinstance(p, dict):
57+
counts[p.get("status", "pending")] = counts.get(p.get("status", "pending"), 0) + 1
58+
59+
report = {
60+
"slug": data.get("slug"),
61+
"title": data.get("title"),
62+
"kind": data.get("kind"),
63+
"appetite": data.get("appetite"),
64+
"branch": data.get("branch"),
65+
"base": data.get("base"),
66+
"top_status": data.get("status"),
67+
"current_phase": data.get("current_phase"),
68+
"review": data.get("review"),
69+
"counts": counts,
70+
"total_phases": len([p for p in phases if isinstance(p, dict)]),
71+
"all_done": nxt is None and counts["blocked"] == 0,
72+
"next_phase": nxt,
73+
"warnings": warnings,
74+
}
75+
print(json.dumps(report, indent=2, ensure_ascii=False))
76+
return 0
77+
78+
79+
if __name__ == "__main__":
80+
raise SystemExit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)