Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions src/socrates120x/onboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,12 @@ def _synthesize_from_answers(project: Path, answers: dict[str, Any]) -> str:
top_risks = [str(r) for r in risks_list[:MAX_BULLETS]]
top_questions = [str(q) for q in open_questions[:MAX_BULLETS]]

current_sprint = "**001 — Discovery & Architecture**" # init-rendered projects start here
# Pick the highest-numbered sprint directory rather than hardcoding "001".
# A project on sprint 005 should not have its WELCOME.md still announce
# "001 — Discovery & Architecture" just because answers.json was the
# original init record.
active_sprint_path = _active_sprint_path(project)
current_sprint = _format_sprint_label(active_sprint_path)
return _format_welcome(
name=name, today=today, tagline=tagline, client=client, tech=tech,
current_sprint=current_sprint, status=status, next_action=next_action,
Expand All @@ -90,7 +94,14 @@ def _synthesize_from_markdown(project: Path) -> str:
client = _extract_field(agents, "Client") or _extract_field(readme, "Client")
tech = _extract_field(agents, "Tech stack")

current_sprint = _extract_section_paragraph(state, "Active sprint") or "_(no active sprint listed)_"
# Prefer the sprint directory listing (ground truth) over whatever
# STATE.md happens to say, which drifts over time. Fall back to STATE.md
# only if no canonical NNN- sprint folders exist.
active_sprint_for_label = _active_sprint_path(project)
if active_sprint_for_label is not None:
current_sprint = _format_sprint_label(active_sprint_for_label)
else:
current_sprint = _extract_section_paragraph(state, "Active sprint") or "_(no active sprint listed)_"
status = _extract_section_paragraph(state, "Status") or "_(no current status)_"
next_action = _extract_section_paragraph(state, "Next action") or "_(no next action)_"

Expand Down Expand Up @@ -261,11 +272,35 @@ def _active_sprint_path(project: Path) -> Path | None:
sprints = project / "planning" / "sprints"
if not sprints.is_dir():
return None
candidates = sorted(p for p in sprints.iterdir() if p.is_dir())
# Heuristic: the highest-numbered sprint folder.
if not candidates:
# Only count folders whose name matches the canonical `NNN-...` pattern;
# ignore stray dirs (drafts, backups). Highest number wins.
canonical = re.compile(r"^(\d{3})-")
numbered = [
(int(m.group(1)), p)
for p in sprints.iterdir()
if p.is_dir() and (m := canonical.match(p.name))
]
if not numbered:
return None
return candidates[-1]
numbered.sort()
return numbered[-1][1]


def _format_sprint_label(sprint: Path | None) -> str:
"""Turn `planning/sprints/005-rebate-engine` into
`**005 — Rebate Engine**` for the WELCOME.md header.
Returns a placeholder if no sprint folder exists."""
if sprint is None:
return "_(no sprint folders found — run `socrates init` first or add planning/sprints/NNN-…)_"
name = sprint.name # e.g. "005-rebate-engine" or "001-discovery-architecture"
m = re.match(r"^(\d{3})-(.+)$", name)
if not m:
return f"**{name}**"
number = m.group(1)
slug = m.group(2)
# Convert kebab-case → Title Case for the human label.
pretty = " ".join(part.capitalize() for part in slug.split("-"))
return f"**{number} — {pretty}**"


def _start_pointer(sprint: Path | None) -> str:
Expand Down
70 changes: 70 additions & 0 deletions tests/test_onboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,73 @@ def test_top_bullets_skips_placeholder_lines() -> None:
# Placeholder italics start with "- _" and we skip those.
bullets = _top_bullets(text, "Risks", 3)
assert bullets == ["real risk one"]


# ---------------------------------------------------------------------------
# Active-sprint derivation (bugfix/onboard-derive-active-sprint-from-dirs)
# ---------------------------------------------------------------------------


def test_synthesize_picks_highest_numbered_sprint_not_001(tmp_path) -> None:
"""A project that has progressed past 001 must not have its WELCOME.md
still claim sprint 001 just because answers.json was the init record.
"""
from socrates120x.onboard import synthesize_welcome
from socrates120x.render import render_all
from socrates120x.scaffold import scaffold

p = tmp_path / "demo"
scaffold(p)
render_all(p, {
"project_name": "demo", "client": "Acme", "tagline": "demo",
"business_goal": "g", "tech_stack": "Python",
"users": [], "current_process": "", "terminology": [],
"business_rules": [], "decisions": [], "out_of_scope": [],
"risks": [], "fragile_inputs": "", "open_questions": [],
"sprint1_goal": "", "sprint1_acceptance": [], "sprint1_inspect": [],
"state_current": "", "state_next": "", "state_blockers": [],
})
# Add a sprint 005 directory after init.
(p / "planning" / "sprints" / "005-rebate-engine").mkdir()
(p / "planning" / "sprints" / "005-rebate-engine" / "requirements.md").write_text(
"# requirements\n", encoding="utf-8",
)

text = synthesize_welcome(p)
# WELCOME.md must call out sprint 005 — NOT 001.
assert "005" in text, "WELCOME.md did not reflect sprint 005"
assert "Rebate Engine" in text
# The old hardcoded label must NOT appear.
assert "001 — Discovery & Architecture" not in text


def test_synthesize_ignores_non_canonical_sprint_dirs(tmp_path) -> None:
"""Stray dirs like `draft/`, `backup-002/`, `notes/` must NOT be picked
as the active sprint — they don't match the canonical NNN- prefix."""
from socrates120x.onboard import synthesize_welcome
from socrates120x.render import render_all
from socrates120x.scaffold import scaffold

p = tmp_path / "demo"
scaffold(p)
render_all(p, {
"project_name": "demo", "client": "Acme", "tagline": "demo",
"business_goal": "g", "tech_stack": "Python",
"users": [], "current_process": "", "terminology": [],
"business_rules": [], "decisions": [], "out_of_scope": [],
"risks": [], "fragile_inputs": "", "open_questions": [],
"sprint1_goal": "", "sprint1_acceptance": [], "sprint1_inspect": [],
"state_current": "", "state_next": "", "state_blockers": [],
})
sprints = p / "planning" / "sprints"
(sprints / "draft").mkdir()
(sprints / "backup-002").mkdir() # leading char is not a digit
(sprints / "notes").mkdir()

text = synthesize_welcome(p)
# Only 001 (from init) is canonical — should be the active label.
assert "001" in text
# Stray dirs must not appear as a sprint label.
assert "draft" not in text
assert "backup-002" not in text
assert "notes" not in text
Loading