mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-10-03 16:16:50 -04:00
309 lines
10 KiB
Python
309 lines
10 KiB
Python
#!/usr/bin/env python
|
|
"""card.py -- manage the HOW_WE_WORK.md card.
|
|
|
|
interview [--name --experience --domain --autonomy --notify --rhythm] [--defaults]
|
|
fill the card's placeholders in place (TTY prompts for missing, --defaults
|
|
writes the template defaults)
|
|
slice <role>
|
|
print, in file order, each section whose role tag names the role
|
|
check print chars/cap/placeholders/standing and exit 1 when over cap,
|
|
with placeholders or a Standing decisions heading
|
|
|
|
Stdlib only; no imports from `pa/`.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
_SCRIPT = "card.py"
|
|
|
|
# placeholder pattern
|
|
_PH_RE = re.compile(r"\{\{(\w+)\}\}")
|
|
|
|
# role tag pattern: <!-- roles: expert coder ... -->
|
|
_ROLE_TAG_RE = re.compile(r"<!--\s*roles:\s*([\w\s,]+?)\s*-->")
|
|
|
|
# section heading pattern
|
|
_HEADING_RE = re.compile(r"^(## .+?)(\s*<!--.*-->)?\s*$")
|
|
|
|
|
|
def _cnote(path, **kw):
|
|
try:
|
|
_d = os.path.dirname(os.path.abspath(__file__))
|
|
if _d not in sys.path:
|
|
sys.path.insert(0, _d)
|
|
import _credit
|
|
_credit.note(path, **kw)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _find_root():
|
|
"""Walk up from cwd to find the project root (has .claude/ or HOW_WE_WORK.md)."""
|
|
d = os.getcwd()
|
|
while True:
|
|
if os.path.isfile(os.path.join(d, "HOW_WE_WORK.md")):
|
|
return d
|
|
parent = os.path.dirname(d)
|
|
if parent == d:
|
|
break
|
|
d = parent
|
|
return os.getcwd()
|
|
|
|
|
|
def _card_path(root):
|
|
return os.path.join(root, "HOW_WE_WORK.md")
|
|
|
|
|
|
def _read_card(root):
|
|
path = _card_path(root)
|
|
with open(path, "r", encoding="utf-8", newline="") as fh:
|
|
return fh.read()
|
|
|
|
|
|
def _write_card(root, text):
|
|
path = _card_path(root)
|
|
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
fh.write(text)
|
|
|
|
|
|
def _max_chars(root):
|
|
"""Read the cap from .claude/pa.json ``card.max_chars`` (default 7000)."""
|
|
pa_json = os.path.join(root, ".claude", "pa.json")
|
|
try:
|
|
with open(pa_json, "r", encoding="utf-8") as fh:
|
|
cfg = json.load(fh)
|
|
return cfg.get("card", {}).get("max_chars", 7000)
|
|
except (OSError, json.JSONDecodeError, KeyError):
|
|
return 7000
|
|
|
|
|
|
def _parse_sections(text):
|
|
"""Parse the card into a list of ``(heading, tag_line, roles, body)`` tuples.
|
|
|
|
*roles* is a set of role names from the heading's tag, or ``None`` when
|
|
there is no tag (printed for every role). *body* is everything from the
|
|
heading line (exclusive) to the next heading (exclusive).
|
|
"""
|
|
sections = []
|
|
lines = text.splitlines(True)
|
|
header_lines = [] # lines before the first ## heading
|
|
current_heading = None
|
|
current_tag_line = None
|
|
current_roles = None
|
|
body_lines = []
|
|
|
|
for line in lines:
|
|
m = _HEADING_RE.match(line)
|
|
if m:
|
|
if current_heading is not None:
|
|
sections.append((current_heading, current_tag_line, current_roles,
|
|
"".join(body_lines)))
|
|
elif header_lines:
|
|
# everything before the first heading is the preamble
|
|
sections.append((None, None, None, "".join(header_lines)))
|
|
current_heading = m.group(1)
|
|
current_tag_line = line
|
|
tag = m.group(2) or ""
|
|
tm = _ROLE_TAG_RE.search(tag)
|
|
if tm:
|
|
current_roles = set(r.strip() for r in re.split(r"[\s,]+", tm.group(1)) if r.strip())
|
|
else:
|
|
current_roles = None
|
|
body_lines = []
|
|
else:
|
|
if current_heading is None:
|
|
header_lines.append(line)
|
|
else:
|
|
body_lines.append(line)
|
|
|
|
# flush last section
|
|
if current_heading is not None:
|
|
sections.append((current_heading, current_tag_line, current_roles,
|
|
"".join(body_lines)))
|
|
elif header_lines:
|
|
sections.append((None, None, None, "".join(header_lines)))
|
|
|
|
return sections
|
|
|
|
|
|
# -- interview ----------------------------------------------------------------
|
|
|
|
_INTERVIEW_FIELDS = {
|
|
"name": ("DEVELOPER_NAME", "Developer name"),
|
|
"experience": ("DEVELOPER_EXPERIENCE", "Experience level (e.g. advanced, intermediate)"),
|
|
"domain": ("DEVELOPER_DOMAIN", "Primary domain"),
|
|
"autonomy": ("AUTONOMY_POSTURE", "Autonomy posture"),
|
|
"notify": ("NOTIFY_CHANNEL", "Notification channel (e.g. toast, discord)"),
|
|
"rhythm": ("RHYTHM", "Rhythm (autonomous or review)"),
|
|
}
|
|
|
|
|
|
def _default_name():
|
|
"""Git user.name or the OS login."""
|
|
try:
|
|
import subprocess
|
|
r = subprocess.run(["git", "config", "user.name"], capture_output=True, text=True, timeout=5)
|
|
if r.returncode == 0 and r.stdout.strip():
|
|
return r.stdout.strip()
|
|
except (OSError, Exception):
|
|
pass
|
|
return os.environ.get("USER") or os.environ.get("USERNAME") or "developer"
|
|
|
|
|
|
def _default_domain(root):
|
|
"""The repo folder name."""
|
|
return os.path.basename(os.path.abspath(root))
|
|
|
|
|
|
def cmd_interview(args, root):
|
|
"""Fill placeholders in the card from interview answers."""
|
|
text = _read_card(root)
|
|
_cnote(_card_path(root), script=_SCRIPT, sub="interview", whole=True, root=root)
|
|
|
|
values = {}
|
|
for field, (placeholder, prompt_text) in _INTERVIEW_FIELDS.items():
|
|
val = getattr(args, field, None)
|
|
if val:
|
|
values[placeholder] = val
|
|
elif args.defaults:
|
|
if field == "name":
|
|
values[placeholder] = _default_name()
|
|
elif field == "domain":
|
|
values[placeholder] = _default_domain(root)
|
|
elif field == "experience":
|
|
values[placeholder] = "advanced"
|
|
elif field == "autonomy":
|
|
values[placeholder] = "full inside an approved plan; stop only at the two gates"
|
|
elif field == "notify":
|
|
values[placeholder] = "toast"
|
|
elif field == "rhythm":
|
|
values[placeholder] = "autonomous"
|
|
elif sys.stdin.isatty():
|
|
default = ""
|
|
if field == "name":
|
|
default = _default_name()
|
|
elif field == "domain":
|
|
default = _default_domain(root)
|
|
elif field == "experience":
|
|
default = "advanced"
|
|
elif field == "autonomy":
|
|
default = "full inside an approved plan; stop only at the two gates"
|
|
elif field == "notify":
|
|
default = "toast"
|
|
elif field == "rhythm":
|
|
default = "autonomous"
|
|
try:
|
|
answer = input("%s [%s]: " % (prompt_text, default))
|
|
except (EOFError, KeyboardInterrupt):
|
|
answer = ""
|
|
values[placeholder] = answer.strip() or default
|
|
|
|
# replace placeholders in text
|
|
for placeholder, val in values.items():
|
|
text = text.replace("{{%s}}" % placeholder, val)
|
|
|
|
# when --defaults, fill any remaining {{...}} with a dash (not a prompt)
|
|
if args.defaults:
|
|
remaining_phs = _PH_RE.findall(text)
|
|
for ph in remaining_phs:
|
|
text = text.replace("{{%s}}" % ph, "—")
|
|
values[ph] = "—"
|
|
|
|
_write_card(root, text)
|
|
_cnote(_card_path(root), script=_SCRIPT, sub="interview",
|
|
added=len(values), root=root)
|
|
remaining = len(_PH_RE.findall(text))
|
|
print("interview: filled %d placeholder(s), %d remaining" % (len(values), remaining))
|
|
return 0
|
|
|
|
|
|
# -- slice --------------------------------------------------------------------
|
|
|
|
def cmd_slice(args, root):
|
|
"""Print sections whose role tag names the given role."""
|
|
role = args.role
|
|
text = _read_card(root)
|
|
_cnote(_card_path(root), script=_SCRIPT, sub="slice", whole=True, root=root)
|
|
sections = _parse_sections(text)
|
|
out = []
|
|
for heading, tag_line, roles, body in sections:
|
|
if heading is None:
|
|
# preamble: always print (the title and comment)
|
|
out.append(body)
|
|
continue
|
|
if roles is None or role in roles:
|
|
out.append(tag_line)
|
|
out.append(body)
|
|
result = "".join(out)
|
|
# strip trailing whitespace but keep one final newline
|
|
print(result.rstrip())
|
|
return 0
|
|
|
|
|
|
# -- check --------------------------------------------------------------------
|
|
|
|
def cmd_check(args, root):
|
|
"""Print card stats and exit 1 when unhealthy."""
|
|
text = _read_card(root)
|
|
_cnote(_card_path(root), script=_SCRIPT, sub="check", whole=True, root=root)
|
|
chars = len(text)
|
|
cap = _max_chars(root)
|
|
placeholders = len(_PH_RE.findall(text))
|
|
standing = 1 if re.search(r"^## Standing decisions", text, re.MULTILINE) else 0
|
|
print("chars=%d cap=%d placeholders=%d standing=%d" % (chars, cap, placeholders, standing))
|
|
if chars > cap or placeholders > 0 or standing:
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# -- main ---------------------------------------------------------------------
|
|
|
|
def main(argv=None):
|
|
try: # the card carries non-ASCII punctuation; a cp1252 console must not break `slice`
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
except (AttributeError, ValueError):
|
|
pass
|
|
p = argparse.ArgumentParser(prog="card.py",
|
|
description="Manage the HOW_WE_WORK.md card.")
|
|
sub = p.add_subparsers(dest="command")
|
|
|
|
iv = sub.add_parser("interview", help="fill card placeholders from answers")
|
|
iv.add_argument("--name", default=None)
|
|
iv.add_argument("--experience", default=None)
|
|
iv.add_argument("--domain", default=None)
|
|
iv.add_argument("--autonomy", default=None)
|
|
iv.add_argument("--notify", default=None)
|
|
iv.add_argument("--rhythm", default=None)
|
|
iv.add_argument("--defaults", action="store_true",
|
|
help="write the template defaults without prompting")
|
|
|
|
sl = sub.add_parser("slice", help="print sections for a role")
|
|
sl.add_argument("role", help="the role name (e.g. expert, router)")
|
|
|
|
sub.add_parser("check", help="print card health and exit 1 when unhealthy")
|
|
|
|
args = p.parse_args(sys.argv[1:] if argv is None else list(argv))
|
|
if not args.command:
|
|
p.print_help()
|
|
return 2
|
|
|
|
root = _find_root()
|
|
|
|
if args.command == "interview":
|
|
return cmd_interview(args, root)
|
|
if args.command == "slice":
|
|
return cmd_slice(args, root)
|
|
if args.command == "check":
|
|
return cmd_check(args, root)
|
|
|
|
p.print_help()
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|