docs(chronology): switch to manual maintenance; delete generator scripts; archive review report
Deleted scripts/audit/generate_chronology.py + chronology_quality_gate.py after repeated corruption incidents (auto-classifier drifted from user intent, silently rewrote rows). chronology.md is now maintained by hand: add a row at the top when a track ships/abandons/is archived. Updated conductor/workflow.md §Chronology Maintenance and conductor/tracks.md §Archiving a track to reflect manual maintenance. Added 40 rows to conductor/chronology.md for the 2026-07-05 archive batch, with archive-folder paths and deferral notes where applicable. Removed 12 archived-track rows from conductor/tracks.md Active Tracks table (rows 2, 3, 7c, 16, 17, 23b, 23c, 23d, 25, 26, 29, 29c) and their track-detail anchors. Wrote docs/reports/ARCHIVE_REVIEW_20260705.md: a categorized index of unfinished work items from the archived tracks' state.toml files that remain relevant to the codebase, with a ranked follow-up list.
This commit is contained in:
@@ -1,126 +0,0 @@
|
||||
"""Quality gate for conductor/chronology.md.
|
||||
|
||||
Checks:
|
||||
1. Needs Review threshold: >30% of rows are Needs Review -> FAIL
|
||||
2. Status distribution sanity: 0 rows are Completed -> FAIL
|
||||
3. Summary quality: >20% of summaries contain metadata-field text -> FAIL
|
||||
4. Per-row evidence: every row must have a non-empty reason -> FAIL
|
||||
|
||||
Modes:
|
||||
default: informational (exits 0, prints report)
|
||||
--strict: CI gate (exits 1 on any violation)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
_METADATA_FIELD_PREFIXES = (
|
||||
"**Priority:**",
|
||||
"**Date:**",
|
||||
"**Initialized:**",
|
||||
"**Track:**",
|
||||
"**Track ID:**",
|
||||
"**Parent umbrella:**",
|
||||
"**Status:**",
|
||||
"**Confidence:**",
|
||||
"**Ancestors:**",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QualityGateResult:
|
||||
passed: bool
|
||||
exit_code: int
|
||||
violations: list[str] = field(default_factory=list)
|
||||
total_rows: int = 0
|
||||
needs_review_count: int = 0
|
||||
completed_count: int = 0
|
||||
metadata_field_count: int = 0
|
||||
empty_reason_count: int = 0
|
||||
|
||||
|
||||
def run_quality_gate(rows: list[dict]) -> QualityGateResult:
|
||||
violations: list[str] = []
|
||||
total: int = len(rows)
|
||||
if total == 0:
|
||||
return QualityGateResult(passed=False, exit_code=1, violations=["0 rows provided"])
|
||||
needs_review_count: int = sum(1 for r in rows if r.get("status") == "Needs Review")
|
||||
completed_count: int = sum(1 for r in rows if r.get("status") == "Completed")
|
||||
metadata_field_count: int = sum(
|
||||
1 for r in rows
|
||||
if any(str(r.get("summary", "")).startswith(p) for p in _METADATA_FIELD_PREFIXES)
|
||||
)
|
||||
empty_reason_count: int = sum(1 for r in rows if not r.get("reason", "").strip())
|
||||
# Check 1: Needs Review threshold (>30%)
|
||||
if total > 0 and (needs_review_count / total) > 0.30:
|
||||
violations.append(
|
||||
f"Needs Review threshold exceeded: {needs_review_count}/{total} "
|
||||
f"({needs_review_count/total*100:.0f}%) > 30%"
|
||||
)
|
||||
# Check 2: 0 rows are Completed
|
||||
if completed_count == 0:
|
||||
violations.append("0 rows are Completed (classifier may be misclassifying everything)")
|
||||
# Check 3: Summary quality (>20% contain metadata-field text)
|
||||
if total > 0 and (metadata_field_count / total) > 0.20:
|
||||
violations.append(
|
||||
f"Summary quality: {metadata_field_count}/{total} "
|
||||
f"({metadata_field_count/total*100:.0f}%) contain metadata-field text > 20%"
|
||||
)
|
||||
# Check 4: Per-row evidence (every row must have non-empty reason)
|
||||
if empty_reason_count > 0:
|
||||
violations.append(f"{empty_reason_count} rows have empty reason (no evidence)")
|
||||
passed: bool = len(violations) == 0
|
||||
return QualityGateResult(
|
||||
passed=passed,
|
||||
exit_code=0 if passed else 1,
|
||||
violations=violations,
|
||||
total_rows=total,
|
||||
needs_review_count=needs_review_count,
|
||||
completed_count=completed_count,
|
||||
metadata_field_count=metadata_field_count,
|
||||
empty_reason_count=empty_reason_count,
|
||||
)
|
||||
|
||||
|
||||
def _load_rows_from_chronology(chronology_path: Path) -> list[dict]:
|
||||
from scripts.audit.generate_chronology import walk_track_folders, _repo_root
|
||||
# chronology_path is conductor/chronology.md; walk_track_folders expects the conductor dir
|
||||
conductor_dir: Path = chronology_path.parent.resolve()
|
||||
if not (conductor_dir / "tracks").is_dir():
|
||||
# Fallback: resolve from repo root
|
||||
repo_root: Path = _repo_root(conductor_dir)
|
||||
conductor_dir = repo_root / "conductor"
|
||||
return walk_track_folders(conductor_dir)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Quality gate for conductor/chronology.md")
|
||||
parser.add_argument("--chronology", type=Path, default=Path("conductor/chronology.md"))
|
||||
parser.add_argument("--rows-json", type=Path, default=None, help="Load rows from JSON file instead of re-walking")
|
||||
parser.add_argument("--strict", action="store_true", help="Exit 1 on any violation")
|
||||
args = parser.parse_args()
|
||||
if args.rows_json and args.rows_json.is_file():
|
||||
import json
|
||||
rows = json.loads(args.rows_json.read_text(encoding="utf-8"))
|
||||
else:
|
||||
rows = _load_rows_from_chronology(args.chronology)
|
||||
result = run_quality_gate(rows)
|
||||
print(f"Quality gate: {'PASS' if result.passed else 'FAIL'}")
|
||||
print(f" Total rows: {result.total_rows}")
|
||||
print(f" Needs Review: {result.needs_review_count}")
|
||||
print(f" Completed: {result.completed_count}")
|
||||
print(f" Metadata-field summaries: {result.metadata_field_count}")
|
||||
print(f" Empty reasons: {result.empty_reason_count}")
|
||||
if result.violations:
|
||||
print(" Violations:")
|
||||
for v in result.violations:
|
||||
print(f" - {v}")
|
||||
if args.strict and result.exit_code != 0:
|
||||
sys.exit(result.exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,468 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate chronology for Manual Slop conductor tracks.
|
||||
|
||||
Walks conductor/tracks/ and conductor/archive/, extracts per-track data
|
||||
(date, ID, status, summary, commit range), and emits a markdown chronology
|
||||
to stdout.
|
||||
|
||||
The v2 classifier uses git-history evidence (work-commit count + report
|
||||
overrides) instead of stale metadata.json.status. Returns
|
||||
(status, confidence, reason) per row.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/audit/generate_chronology.py --draft
|
||||
uv run python scripts/audit/generate_chronology.py --root conductor/
|
||||
uv run python scripts/audit/generate_chronology.py # JSON dump
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_SLUG_DATE_RE = re.compile(r"\d{8}$")
|
||||
_SENTENCE_END_RE = re.compile(r"\.\s")
|
||||
_GIT_TIMEOUT = 30
|
||||
_DEFAULT_ROOT = "conductor/"
|
||||
|
||||
_METADATA_FIELD_PREFIXES = (
|
||||
"**Priority:**",
|
||||
"**Date:**",
|
||||
"**Initialized:**",
|
||||
"**Track:**",
|
||||
"**Track ID:**",
|
||||
"**Parent umbrella:**",
|
||||
"**Status:**",
|
||||
"**Confidence:**",
|
||||
"**Ancestors:**",
|
||||
)
|
||||
|
||||
_WORK_COMMIT_PREFIXES = ("feat", "fix", "refactor", "perf", "test", "docs(report)")
|
||||
_METADATA_COMMIT_PREFIXES = ("conductor(plan):", "conductor(state):", "conductor(track):", "docs(spec):", "docs(plan):")
|
||||
|
||||
|
||||
def _is_work_commit(msg: str) -> bool:
|
||||
"""Check if a commit message is a work commit (feat/fix/refactor/perf/test with optional scope)."""
|
||||
for prefix in _WORK_COMMIT_PREFIXES:
|
||||
if msg.startswith(prefix + ":") or msg.startswith(prefix + "("):
|
||||
if not any(msg.startswith(m) for m in _METADATA_COMMIT_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_slug_date(folder_name: str) -> Optional[str]:
|
||||
m = _SLUG_DATE_RE.search(folder_name)
|
||||
if not m:
|
||||
return None
|
||||
raw: str = m.group(0)
|
||||
return f"{raw[:4]}-{raw[4:6]}-{raw[6:]}"
|
||||
|
||||
|
||||
def _md_escape(text: str) -> str:
|
||||
return text.replace("|", "\\|").replace("\n", " ").replace("\r", " ")
|
||||
|
||||
|
||||
def _to_posix(path_str: str) -> str:
|
||||
return path_str.replace("\\", "/")
|
||||
|
||||
|
||||
def _first_sentence(line: str) -> str:
|
||||
m = _SENTENCE_END_RE.search(line)
|
||||
if m:
|
||||
return line[: m.start() + 1].strip()
|
||||
return line.strip()
|
||||
|
||||
|
||||
def _truncate_to_25_words(text: str) -> str:
|
||||
words: list[str] = text.split()
|
||||
if len(words) <= 25:
|
||||
return text
|
||||
return " ".join(words[:25]) + "\u2026"
|
||||
|
||||
|
||||
def extract_summary(folder_path: Path) -> str:
|
||||
md_path = folder_path / "metadata.json"
|
||||
if md_path.is_file():
|
||||
try:
|
||||
data = json.loads(md_path.read_text(encoding="utf-8"))
|
||||
desc = str(data.get("description", "")).strip()
|
||||
if desc and not desc.startswith(_METADATA_FIELD_PREFIXES):
|
||||
return _truncate_to_25_words(_first_sentence(desc))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
for fname in ("spec.md", "plan.md"):
|
||||
fpath = folder_path / fname
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
try:
|
||||
text = fpath.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
if stripped.startswith(">"):
|
||||
continue
|
||||
bare = stripped.lstrip(">").strip()
|
||||
if bare.startswith(_METADATA_FIELD_PREFIXES):
|
||||
continue
|
||||
return _truncate_to_25_words(_first_sentence(bare))
|
||||
return "Imported from archive (no spec)"
|
||||
|
||||
|
||||
def _git_log(folder_relpath: str, *args: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", *args, "--", folder_relpath],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
def _git_log_multi(*folder_relpaths: str) -> str:
|
||||
"""Get git log for multiple paths in a single subprocess call."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", "--", *folder_relpaths],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
def _git_first_line(folder_relpath: str, *args: str) -> str:
|
||||
out = _git_log(folder_relpath, *args)
|
||||
stripped = out.strip()
|
||||
if not stripped:
|
||||
return ""
|
||||
return stripped.splitlines()[0]
|
||||
|
||||
|
||||
def _repo_root(start: Path) -> Path:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
cwd=str(start),
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return Path(result.stdout.strip())
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
return start.parent
|
||||
|
||||
|
||||
def _parse_state_phase(state_path: Path) -> str:
|
||||
if not state_path.is_file():
|
||||
return "no-state-toml"
|
||||
try:
|
||||
for line in state_path.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("current_phase"):
|
||||
v = line.split("=", 1)[1].strip().split("#")[0].strip().strip('"')
|
||||
return v
|
||||
except (OSError, Exception):
|
||||
pass
|
||||
return "?"
|
||||
|
||||
|
||||
def _parse_state_status(state_path: Path) -> str:
|
||||
if not state_path.is_file():
|
||||
return ""
|
||||
try:
|
||||
text = state_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return ""
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("status") and "=" in stripped:
|
||||
parts = stripped.split("=", 1)
|
||||
if len(parts) == 2:
|
||||
val: str = parts[1].split("#")[0].strip()
|
||||
val = val.strip('"').strip("'").strip()
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
def _last_commit_date(folder_relpath: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%ad", "--date=short", "--", folder_relpath],
|
||||
capture_output=True, text=True, timeout=_GIT_TIMEOUT, check=False,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return "never"
|
||||
|
||||
|
||||
def _count_work_commits_from_log(log: str) -> int:
|
||||
count: int = 0
|
||||
for line in log.splitlines():
|
||||
msg: str = line.split(" ", 1)[1] if " " in line else ""
|
||||
if _is_work_commit(msg):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _count_work_commits(folder_relpath: str) -> int:
|
||||
log: str = _git_log(folder_relpath, "--oneline")
|
||||
return _count_work_commits_from_log(log)
|
||||
|
||||
|
||||
def _has_report_matching(reports_dir: Path, track_id: str, prefix: str) -> bool:
|
||||
if not reports_dir.is_dir():
|
||||
return False
|
||||
for f in reports_dir.iterdir():
|
||||
if f.is_file() and f.name.startswith(prefix) and track_id in f.name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def classify_status(
|
||||
folder_link: str,
|
||||
current: str,
|
||||
track_id: str,
|
||||
repo_root: Path,
|
||||
reports_dir: Path,
|
||||
has_abort_report: bool = False,
|
||||
state_status: str = "",
|
||||
work_commits: int = -1,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Git-history evidence classifier returning (status, confidence, reason).
|
||||
|
||||
Evidence priority:
|
||||
1. Override signals (highest): state.toml status (human-set), TRACK_COMPLETION/TRACK_ABORTED reports
|
||||
2. Git commit evidence (medium): work-commit count
|
||||
3. Directory location (low): archive/ vs tracks/
|
||||
4. Fallback: Needs Review
|
||||
"""
|
||||
if "PLACEHOLDER" in track_id:
|
||||
return ("Special", "high", "placeholder track")
|
||||
if "contingency" in current.lower():
|
||||
return ("Special", "high", "contingency track")
|
||||
# 1. Override signals — state.toml is human-set, trust it
|
||||
if state_status == "superseded":
|
||||
return ("Superseded", "high", "state.toml status=superseded")
|
||||
if state_status in ("completed", "complete", "shipped"):
|
||||
return ("Completed", "high", f"state.toml status={state_status}")
|
||||
if state_status == "abandoned":
|
||||
return ("Abandoned", "high", "state.toml status=abandoned")
|
||||
if state_status == "archived":
|
||||
return ("Completed", "high", "state.toml status=archived (treated as completed)")
|
||||
if has_abort_report or _has_report_matching(reports_dir, track_id, "TRACK_ABORTED_"):
|
||||
return ("Abandoned", "high", "abort report found")
|
||||
if _has_report_matching(reports_dir, track_id, "TRACK_COMPLETION_"):
|
||||
return ("Completed", "high", "completion report found")
|
||||
# 2. Git commit evidence
|
||||
is_archive = folder_link.startswith("conductor/archive/")
|
||||
is_tracks = folder_link.startswith("conductor/tracks/")
|
||||
if work_commits < 0:
|
||||
work_commits = _count_work_commits(folder_link)
|
||||
if work_commits >= 3:
|
||||
return ("Completed", "medium", f"{work_commits} work commits")
|
||||
if 1 <= work_commits <= 2 and is_tracks:
|
||||
return ("In Progress", "medium", f"{work_commits} work commits in tracks/")
|
||||
if work_commits == 0 and is_tracks:
|
||||
return ("Active", "medium", "0 work commits in tracks/ (spec/plan only)")
|
||||
# 3. Directory location
|
||||
if is_archive:
|
||||
# Archive tracks: the git mv to archive/ is a signal but not a definitive one.
|
||||
# Tracks can be archived because they're completed OR because they're abandoned.
|
||||
# Check the metadata.json status for human-set values, then fall back to Needs Review.
|
||||
# (The state.toml override and TRACK_ABORTED report check already ran above.)
|
||||
if work_commits >= 3:
|
||||
return ("Completed", "medium", f"{work_commits} work commits")
|
||||
# Check for plan-progression / "mark as completed" heuristics
|
||||
full_log: str = _git_log(folder_link, "--oneline")
|
||||
if folder_link.startswith("conductor/archive/"):
|
||||
original: str = folder_link.replace("conductor/archive/", "conductor/tracks/", 1)
|
||||
full_log = _git_log(original, "--oneline") + full_log
|
||||
plan_progression: int = sum(
|
||||
1 for line in full_log.splitlines()
|
||||
if ("mark" in line.lower() and "complete" in line.lower())
|
||||
or "complete" in line.lower() and ("phase" in line.lower() or "track" in line.lower() or "task" in line.lower() or "verification" in line.lower() or "audit" in line.lower())
|
||||
)
|
||||
if plan_progression >= 1:
|
||||
return ("Completed", "low", f"archived with {plan_progression} completion-signal commits")
|
||||
archive_move_completed: bool = any(
|
||||
"completed" in line.lower() and ("archive" in line.lower() or "move" in line.lower())
|
||||
for line in full_log.splitlines()[:3]
|
||||
)
|
||||
if archive_move_completed:
|
||||
return ("Completed", "low", "archived with 'completed' in archive-move commit")
|
||||
# No evidence of completion (no mark-complete, no feat commits, no report).
|
||||
# If the track folder has planning commits only, it was likely planned but never
|
||||
# implemented (abandoned). The work would have shown up as feat/fix commits.
|
||||
if work_commits == 0:
|
||||
# Last resort: check if "complete" appears in any commit message at all
|
||||
has_any_complete: bool = any("complete" in line.lower() for line in full_log.splitlines())
|
||||
if has_any_complete:
|
||||
return ("Completed", "low", "archived with 'complete' in commit messages")
|
||||
return ("Abandoned", "low", "archived with 0 work commits and 0 completion signals")
|
||||
return ("Completed", "low", f"archived with {work_commits} work commits on track folder")
|
||||
# 4. Fallback
|
||||
return ("Needs Review", "none", "classifier inconclusive")
|
||||
|
||||
|
||||
def walk_track_folders(root: Path) -> list[dict]:
|
||||
repo_root: Path = _repo_root(root)
|
||||
reports_dir: Path = repo_root / "docs" / "reports"
|
||||
rows: list[dict] = []
|
||||
for parent_dir, default_status in (
|
||||
(root / "tracks", "Active"),
|
||||
(root / "archive", "Completed"),
|
||||
):
|
||||
if not parent_dir.is_dir():
|
||||
continue
|
||||
for folder in sorted(parent_dir.iterdir()):
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
try:
|
||||
folder_relpath = _to_posix(str(folder.relative_to(repo_root)))
|
||||
except ValueError:
|
||||
folder_relpath = _to_posix(str(folder))
|
||||
track_id: str = folder.name
|
||||
slug_date = extract_slug_date(track_id)
|
||||
# Get the full oneline log once. For archive folders, include both the
|
||||
# archive path and the original tracks/ path (git mv preserves history
|
||||
# but git log -- <archive_path> alone may miss pre-move commits)
|
||||
if folder_relpath.startswith("conductor/archive/"):
|
||||
original_path: str = folder_relpath.replace("conductor/archive/", "conductor/tracks/", 1)
|
||||
oneline_log: str = _git_log_multi(folder_relpath, original_path)
|
||||
else:
|
||||
oneline_log = _git_log(folder_relpath, "--oneline")
|
||||
log_lines: list[str] = [l for l in oneline_log.splitlines() if l.strip()]
|
||||
commit_count: int = len(log_lines)
|
||||
if slug_date:
|
||||
date = slug_date
|
||||
elif log_lines:
|
||||
# First commit date via reverse log
|
||||
first_commit = _git_first_line(folder_relpath, "--reverse", "--format=%aI")
|
||||
date = first_commit[:10] if first_commit else ""
|
||||
else:
|
||||
date = ""
|
||||
# Derive init_sha and end_sha from the oneline log
|
||||
if log_lines:
|
||||
init_sha: str = log_lines[-1].split(" ", 1)[0] # oldest (last in reverse-chronological git log)
|
||||
end_sha: str = log_lines[0].split(" ", 1)[0] # newest (first in git log)
|
||||
else:
|
||||
init_sha = ""
|
||||
end_sha = ""
|
||||
metadata_path = folder / "metadata.json"
|
||||
meta_status: str = ""
|
||||
if metadata_path.is_file():
|
||||
try:
|
||||
data = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
meta_status = str(data.get("status", "")).strip()
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
state_status: str = _parse_state_status(folder / "state.toml")
|
||||
work_commits: int = _count_work_commits_from_log(oneline_log)
|
||||
status, confidence, reason = classify_status(
|
||||
folder_link=folder_relpath,
|
||||
current=meta_status or default_status,
|
||||
track_id=track_id,
|
||||
repo_root=repo_root,
|
||||
reports_dir=reports_dir,
|
||||
state_status=state_status,
|
||||
work_commits=work_commits,
|
||||
)
|
||||
summary: str = extract_summary(folder)
|
||||
try:
|
||||
folder_link = _to_posix(str(folder.relative_to(repo_root)))
|
||||
except ValueError:
|
||||
folder_link = _to_posix(str(folder))
|
||||
rows.append({
|
||||
"date": date,
|
||||
"track_id": track_id,
|
||||
"status": status,
|
||||
"confidence": confidence,
|
||||
"reason": reason,
|
||||
"summary": summary,
|
||||
"init_sha": init_sha,
|
||||
"end_sha": end_sha,
|
||||
"commit_count": commit_count,
|
||||
"folder_link": folder_link,
|
||||
})
|
||||
rows.sort(key=lambda r: r["track_id"])
|
||||
rows.sort(key=lambda r: r["date"], reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def format_markdown(rows: list[dict]) -> str:
|
||||
from datetime import date as today_date
|
||||
lines: list[str] = []
|
||||
lines.append(f"<!-- Generated {today_date.today().isoformat()} | {len(rows)} rows -->")
|
||||
lines.append("")
|
||||
lines.append("| Date | ID | Status | Summary | Folder | Range |")
|
||||
lines.append("| --- | --- | --- | --- | --- | --- |")
|
||||
for r in rows:
|
||||
range_str: str = f"`{r['init_sha']}..{r['end_sha']}` ({r['commit_count']})" if r["init_sha"] else "n/a"
|
||||
lines.append(
|
||||
f"| {r['date']} | `{r['track_id']}` | {r['status']} | "
|
||||
f"{_md_escape(r['summary'])} | `{r['folder_link']}` | {range_str} |"
|
||||
)
|
||||
needs_review = [r for r in rows if r["status"] == "Needs Review"]
|
||||
if needs_review:
|
||||
lines.append("")
|
||||
lines.append("## Needs Review")
|
||||
lines.append("")
|
||||
for r in needs_review:
|
||||
lines.append(f"- `{r['track_id']}` (`{r['folder_link']}`): {r['reason']}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate chronology for Manual Slop conductor tracks.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--draft",
|
||||
action="store_true",
|
||||
help="Emit markdown table to stdout.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
default=_DEFAULT_ROOT,
|
||||
help=f"Path to conductor root (default: {_DEFAULT_ROOT}).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
root = Path(args.root)
|
||||
if not root.is_absolute():
|
||||
root = Path.cwd() / root
|
||||
rows = walk_track_folders(root)
|
||||
if args.draft:
|
||||
sys.stdout.write(format_markdown(rows))
|
||||
else:
|
||||
sys.stdout.write(json.dumps(rows, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user