Apply Development Effort Calculator to real pilot candidates (WP-0010-T03)

Adds since/until date-range scoping to cluster_commit_hours() and
workplan_task_counts() (threaded through calculate_target_basis()),
needed whenever a candidate is one bounded workplan within a repo
whose overall history spans much more (net-kingdom, railiance-apps)
rather than the whole repo being the candidate (vergabe-teilnahme,
info-tech-canon).

Fixes a real bug found along the way: workplan_task_counts() only
scanned the top level of workplans/, missing net-kingdom's
workplans/archived/ convention entirely - silently reported zero
finished workplans for NK-WP-0002, which lives there. Fixed to scan
recursively; added a regression test.

Updates all three draft pilot-candidate manifests with calculator-
derived target_basis/initial_target values, replacing the hand-picked
placeholders:
  net-kingdom-local-identity:      200,000 -> 10,000 EUR (floor + sanity warnings)
  railiance-vergabe-teilnahme:   3,500,000 -> 648,800 EUR (no warnings)
  info-tech-canon-service-surface: 2,500,000 -> 141,800 EUR (sanity warning)

history/260730-EffortCalculator-CandidateApplication.md records full
derivation, warnings, and the judgment calls made explicit rather than
silently picked (date-scoping windows; measuring vergabe-teilnahme's
own repo rather than railiance-apps' deployment-only wiring, with both
figures shown). Still draft/non-binding - WP-0008-T05 unaffected.

5 new tests (20 -> now covering since/until scoping and the
archived-subdirectory fix). Full suite: 84 passing offline.
This commit is contained in:
tegwick 2026-07-30 13:29:06 +02:00
parent 78a5e72bbf
commit 6eec4f6634
9 changed files with 405 additions and 25 deletions

View file

@ -113,7 +113,10 @@ class TargetBasisEstimate:
def cluster_commit_hours(
repo_path: str | Path, session_gap_hours: float = DEFAULT_SESSION_GAP_HOURS
repo_path: str | Path,
session_gap_hours: float = DEFAULT_SESSION_GAP_HOURS,
since: str | None = None,
until: str | None = None,
) -> CommitTimeEstimate:
"""Group commit timestamps into sessions (gap-based clustering).
@ -126,11 +129,20 @@ def cluster_commit_hours(
This is an explicit floor estimate (concept §2a), not a true count
time spent reading, thinking, or working without committing is
invisible to it by construction.
`since`/`until` (any `git log --since`/`--until`-accepted date string,
e.g. `"2026-03-01"`) scope the estimate to a date range required
whenever the candidate Milestone Release is one bounded workplan
within a repo whose overall history spans much more unrelated work
than that one Phase. Whole-repo history (the default, no scoping) is
only correct when the repo's entire history *is* the candidate.
"""
result = subprocess.run(
["git", "-C", str(repo_path), "log", "--all", "--format=%at"],
capture_output=True, text=True, check=True,
)
cmd = ["git", "-C", str(repo_path), "log", "--all", "--format=%at"]
if since:
cmd += ["--since", since]
if until:
cmd += ["--until", until]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
timestamps = sorted(int(line) for line in result.stdout.splitlines() if line.strip())
if not timestamps:
return CommitTimeEstimate(raw_hours=0.0, session_count=0, commit_count=0)
@ -161,16 +173,34 @@ def cluster_commit_hours(
_TASK_BLOCK_RE = re.compile(r"```task\n(.*?)\n```", re.DOTALL)
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
_STATUS_FIELD_RE = re.compile(r'^status:\s*"?([^"\n]+)"?\s*$', re.MULTILINE)
_UPDATED_FIELD_RE = re.compile(r'^updated:\s*"?([0-9]{4}-[0-9]{2}-[0-9]{2})"?\s*$', re.MULTILINE)
_CREATED_FIELD_RE = re.compile(r'^created:\s*"?([0-9]{4}-[0-9]{2}-[0-9]{2})"?\s*$', re.MULTILINE)
def workplan_task_counts(repo_path: str | Path) -> WorkplanTaskCounts:
def workplan_task_counts(
repo_path: str | Path, since: str | None = None, until: str | None = None
) -> WorkplanTaskCounts:
"""Count finished workplans and tasks under `<repo>/workplans/`.
Follows this repo's own workplan convention (YAML-ish frontmatter with
a `status:` field, ```task fenced blocks each with their own
`status:` line) rather than requiring state-hub connectivity works
on any repo using this convention, degrades to zero (not an error) for
repos with no `workplans/` directory at all.
repos with no `workplans/` directory at all. Scans recursively (not
just the top level), since several repos in this ecosystem archive
finished workplans under `workplans/archived/` rather than deleting
them a non-recursive scan would silently undercount exactly the
finished work this function exists to measure.
`since`/`until` (`YYYY-MM-DD` strings) restrict counting to workplans
whose frontmatter `updated` (falling back to `created`) date falls
within the window a workplan file with neither field is always
counted, since there is nothing to filter it by. Pass matching
`since`/`until` values to `cluster_commit_hours` when scoping a
candidate to one bounded workplan within a repo whose overall history
spans much more; otherwise a whole-repo workplan count compared
against a date-scoped time estimate produces a misleading sanity-check
signal (falsely flagging under-counting).
"""
workplans_dir = Path(repo_path) / "workplans"
if not workplans_dir.is_dir():
@ -180,15 +210,26 @@ def workplan_task_counts(repo_path: str | Path) -> WorkplanTaskCounts:
total_tasks = 0
finished_tasks = 0
for md_file in sorted(workplans_dir.glob("*.md")):
for md_file in sorted(workplans_dir.rglob("*.md")):
try:
text = md_file.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
frontmatter_match = _FRONTMATTER_RE.match(text)
frontmatter = frontmatter_match.group(1) if frontmatter_match else ""
if since or until:
date_match = _UPDATED_FIELD_RE.search(frontmatter) or _CREATED_FIELD_RE.search(frontmatter)
if date_match:
date_str = date_match.group(1)
if since and date_str < since:
continue
if until and date_str > until:
continue
if frontmatter_match:
status_match = _STATUS_FIELD_RE.search(frontmatter_match.group(1))
status_match = _STATUS_FIELD_RE.search(frontmatter)
if status_match and status_match.group(1).strip() == "finished":
finished_workplans += 1
@ -336,6 +377,8 @@ def calculate_target_basis(
hours_per_day: float = DEFAULT_HOURS_PER_DAY,
extra_excludes: frozenset[str] = frozenset(),
model_pricing: dict[str, dict[str, float]] | None = None,
since: str | None = None,
until: str | None = None,
) -> TargetBasisEstimate:
"""End-to-end convenience entry point: repo path + token counts in,
a proposed `target_basis` estimate (with warnings and derivation) out.
@ -344,9 +387,22 @@ def calculate_target_basis(
hub's `get_token_summary`) rather than fetched here, since this
module has no MCP/network client of its own keeping it offline-first
like the rest of this library.
`since`/`until` scope both commit-time clustering and workplan/task
counting to the same date window pass both whenever the candidate
Milestone Release is one bounded workplan within a repo whose overall
history spans much more (see `cluster_commit_hours`/
`workplan_task_counts` docstrings); leave unset only when the repo's
entire history genuinely is the candidate. Repo size (file/line
counts) is not date-scoped it reflects the repo's current state
regardless of when each file was last touched, which is intentional
(`target_basis` describes the Milestone Release's actual delivered
size, not a historical snapshot) but worth noting as an asymmetry.
"""
commit_time = cluster_commit_hours(repo_path, session_gap_hours=session_gap_hours)
workplan_tasks = workplan_task_counts(repo_path)
commit_time = cluster_commit_hours(
repo_path, session_gap_hours=session_gap_hours, since=since, until=until
)
workplan_tasks = workplan_task_counts(repo_path, since=since, until=until)
repo_size = repo_size_metrics(repo_path, extra_excludes=extra_excludes)
usd = token_cost_usd(tokens_in, tokens_out, model=model, model_pricing=model_pricing)
token_cost = TokenCost(tokens_in=tokens_in, tokens_out=tokens_out, usd=usd)