code-factory-1-spec 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
specline/gates.py ADDED
@@ -0,0 +1,40 @@
1
+ """Human review gates with receipts. A gate signoff writes a hash-sealed
2
+ line into PROGRESS.md; the loop and drift guard consume those seals."""
3
+ from __future__ import annotations
4
+ import hashlib, datetime
5
+ from pathlib import Path
6
+ from .spec_lint import validate_spec
7
+ from .plan_lint import lint_plan
8
+ from .strict_lint import strict_errors
9
+
10
+ def _seal(root: Path, line: str):
11
+ p = Path(root)/"context"/"PROGRESS.md"
12
+ ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M")
13
+ p.write_text(p.read_text() + f"\n- [{ts}] {line}")
14
+
15
+ def gate(root: Path, phase: str, feature: str, approver: str = "human", strict: bool = True) -> dict:
16
+ root = Path(root)
17
+ spec = root/"specs"/f"{feature}.md"
18
+ if phase == "spec":
19
+ errs = validate_spec(spec)
20
+ if strict:
21
+ errs = errs + strict_errors(spec)
22
+ if errs:
23
+ raise SystemExit("spec gate FAILED:\n" + "\n".join(errs))
24
+ sha = hashlib.sha256(spec.read_bytes()).hexdigest()[:16]
25
+ _seal(root, f"GATE spec {feature} approver={approver} strict={strict} sha={sha}")
26
+ return {"gate": "spec", "sha": sha}
27
+ if phase == "plan":
28
+ errsS = validate_spec(spec)
29
+ if strict:
30
+ errsS = errsS + strict_errors(spec)
31
+ tasks, errsP = lint_plan(root/"plans"/f"{feature}.md")
32
+ if errsS or errsP:
33
+ raise SystemExit("plan gate FAILED:\n" + "\n".join(errsS + errsP))
34
+ sha = hashlib.sha256(spec.read_bytes()).hexdigest()[:16]
35
+ _seal(root, f"GATE plan {feature} approver={approver} tasks={len(tasks)} sha={sha}")
36
+ return {"gate": "plan", "tasks": len(tasks), "sha": sha}
37
+ if phase == "code":
38
+ _seal(root, f"GATE code {feature} approver={approver} reviewer=personas/reviewer.md auditor=personas/security_auditor.md")
39
+ return {"gate": "code"}
40
+ raise SystemExit(f"unknown gate {phase}")
specline/handoff.py ADDED
@@ -0,0 +1,44 @@
1
+ """The HSF calibration bridge: decision tables in specs become Harness
2
+ Software Factory workflow specs — compiled once, gated, deterministic —
3
+ instead of agent-improvised if-statements scattered through tissue code."""
4
+ from __future__ import annotations
5
+ import re
6
+ from pathlib import Path
7
+ import yaml
8
+ from .spec_lint import decision_rows
9
+
10
+ def handoff_to_hsf(root: Path, feature: str, owner: str = "specline") -> Path:
11
+ root = Path(root)
12
+ rows = decision_rows(root/"specs"/f"{feature}.md")
13
+ if not rows:
14
+ raise SystemExit("no decision table found in spec (## Decision logic section)")
15
+ fields: set[str] = set()
16
+ rules = []
17
+ for r in rows:
18
+ cond = r["if"].strip()
19
+ is_else = cond.lower() in {"else", "otherwise", "*"}
20
+ if not is_else:
21
+ for name in re.findall(r"[a-z_][a-z0-9_]*", cond):
22
+ if name not in {"and", "or", "not", "true", "false"}:
23
+ fields.add(name)
24
+ m = re.match(r"(\w+)\s*[:=]\s*(.+)", r["then"])
25
+ status, reason = (m.group(1).upper(), m.group(2)) if m else ("APPROVED", r["then"])
26
+ if is_else:
27
+ rules.append({"else": {"status": status, "reason": reason}})
28
+ else:
29
+ rules.append({"if": cond, "then": {"status": status, "reason": reason}})
30
+ if not any("else" in r for r in rules):
31
+ rules.append({"else": {"status": "HUMAN_REVIEW", "reason": "No rule matched"}})
32
+ schema = {f: "boolean" if re.search(rf"{f}\s*==\s*(true|false)", " ".join(r['if'] for r in rows if 'if' in r)) else {"type": "float", "min": 0.0, "max": 1000000.0} for f in sorted(fields)}
33
+ spec = {"workflow_spec": f"{feature}_decisions", "version": 1,
34
+ "metadata": {"owner": owner, "compliance": []},
35
+ "inputs": {"input_data": {"text": "string"}},
36
+ "steps": [
37
+ {"id": "extract_facts", "type": "bounded_invocation",
38
+ "schema": schema, "on_out_of_bounds": "human_review"},
39
+ {"id": "decide", "type": "branch", "rules": rules}],
40
+ "outputs": {"AuthResult": {"status": "enum[APPROVED, DENIED, HUMAN_REVIEW]", "reason": "string"}}}
41
+ out = root/"handoff"/f"{feature}_decisions.yaml"
42
+ out.parent.mkdir(exist_ok=True)
43
+ out.write_text(yaml.safe_dump(spec, sort_keys=False))
44
+ return out
specline/ledger.py ADDED
@@ -0,0 +1,40 @@
1
+ """Context Ledger — receipts for token economics. Every packet logs its
2
+ estimated context cost vs the naive baseline (re-reading the whole feature
3
+ surface each session). `specline status` shows cumulative savings —
4
+ the SDD equivalent of HSF's break-even bench."""
5
+ from __future__ import annotations
6
+ import json, datetime
7
+ from pathlib import Path
8
+
9
+ LEDGER = "receipts/context-ledger.jsonl"
10
+
11
+ def _naive_baseline_tokens(root: Path, feature: str) -> int:
12
+ total = 0
13
+ for p in [root/"specs"/f"{feature}.md", root/"plans"/f"{feature}.md", root/"AGENTS.md"]:
14
+ if p.exists(): total += len(p.read_text())
15
+ for d in ["context", "slices"]:
16
+ for p in (root/d).rglob("*.*"):
17
+ try: total += len(p.read_text())
18
+ except Exception: pass
19
+ return total // 4
20
+
21
+ def log_packet(root: Path, feature: str, task_id: str, tokens_est: int, sha: str) -> dict:
22
+ root = Path(root)
23
+ entry = {"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
24
+ "feature": feature, "task": task_id, "packet_tokens": tokens_est,
25
+ "naive_baseline_tokens": _naive_baseline_tokens(root, feature), "sha": sha}
26
+ lp = root/LEDGER
27
+ lp.parent.mkdir(exist_ok=True)
28
+ with lp.open("a") as f:
29
+ f.write(json.dumps(entry) + "\n")
30
+ return entry
31
+
32
+ def summarize(root: Path) -> dict:
33
+ lp = Path(root)/LEDGER
34
+ if not lp.exists():
35
+ return {"sessions": 0, "packet_tokens": 0, "naive_tokens": 0, "saved_pct": 0.0}
36
+ rows = [json.loads(l) for l in lp.read_text().splitlines() if l.strip()]
37
+ pt = sum(r["packet_tokens"] for r in rows)
38
+ nt = sum(r["naive_baseline_tokens"] for r in rows)
39
+ return {"sessions": len(rows), "packet_tokens": pt, "naive_tokens": nt,
40
+ "saved_pct": round(100 * (1 - pt / nt), 1) if nt else 0.0}
specline/loop.py ADDED
@@ -0,0 +1,66 @@
1
+ """The Ralph Wiggum Loop runner. `next` emits one packet; `done` verifies
2
+ and seals it with a receipt in PROGRESS.md. Drift guard: if the spec hash
3
+ changed since plan approval, the loop refuses until the plan is re-gated."""
4
+ from __future__ import annotations
5
+ import hashlib, subprocess, datetime
6
+ from pathlib import Path
7
+ from .plan_lint import parse_tasks, lint_plan
8
+ from .packets import build_packet
9
+ from .ledger import log_packet
10
+
11
+ def _sha(p: Path) -> str:
12
+ return hashlib.sha256(p.read_bytes()).hexdigest()[:16]
13
+
14
+ def _progress(root: Path) -> Path:
15
+ return Path(root)/"context"/"PROGRESS.md"
16
+
17
+ def _append_progress(root: Path, line: str):
18
+ p = _progress(root)
19
+ ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M")
20
+ p.write_text(p.read_text() + f"\n- [{ts}] {line}")
21
+
22
+ def _approved_spec_sha(root: Path, feature: str) -> str | None:
23
+ for line in _progress(root).read_text().splitlines():
24
+ if f"GATE plan {feature}" in line and "sha=" in line:
25
+ return line.split("sha=")[-1].strip()
26
+ return None
27
+
28
+ def next_task(root: Path, feature: str) -> dict:
29
+ root = Path(root)
30
+ plan = root/"plans"/f"{feature}.md"
31
+ tasks, errors = lint_plan(plan)
32
+ if errors:
33
+ raise SystemExit("plan not clean:\n" + "\n".join(errors))
34
+ locked = _approved_spec_sha(root, feature)
35
+ current = _sha(root/"specs"/f"{feature}.md")
36
+ if locked is None:
37
+ raise SystemExit("plan gate missing: run `specline gate plan " + feature + "` first")
38
+ if locked != current:
39
+ raise SystemExit(f"E_INTENT_DRIFT: spec changed since plan gate (locked {locked}, now {current}). Re-gate the plan.")
40
+ todo = [t for t in tasks if not t["done"]]
41
+ if not todo:
42
+ return {"status": "all_done"}
43
+ task = todo[0]
44
+ packet, meta = build_packet(root, feature, task)
45
+ entry = log_packet(root, feature, task["id"], meta["tokens_est"], meta["sha"])
46
+ _append_progress(root, f"PACKET {feature} {task['id']} tokens={meta['tokens_est']} sha={meta['sha']}")
47
+ return {"status": "ready", "task": task["id"], "packet": str(packet),
48
+ "tokens_est": meta["tokens_est"], "saved_vs_naive": entry["naive_baseline_tokens"] - meta["tokens_est"]}
49
+
50
+ def mark_done(root: Path, feature: str, task_id: str, run_verify: bool = True) -> dict:
51
+ root = Path(root)
52
+ plan = root/"plans"/f"{feature}.md"
53
+ tasks = parse_tasks(plan)
54
+ task = next((t for t in tasks if t["id"] == task_id), None)
55
+ if task is None:
56
+ raise SystemExit(f"unknown task {task_id}")
57
+ verified = True
58
+ if run_verify:
59
+ proc = subprocess.run(task["verify"], shell=True, cwd=root, capture_output=True, text=True)
60
+ verified = proc.returncode == 0
61
+ if not verified:
62
+ raise SystemExit(f"VERIFY FAILED for {task_id}: {task['verify']}\n{proc.stderr[-500:]}")
63
+ text = plan.read_text().replace(f"- [ ] {task_id} |", f"- [x] {task_id} |", 1)
64
+ plan.write_text(text)
65
+ _append_progress(root, f"DONE {feature} {task_id} verify_pass=true # context resets now")
66
+ return {"task": task_id, "verified": verified}
specline/packets.py ADDED
@@ -0,0 +1,107 @@
1
+ """Task Packets — the token-lean session brief. This is C_t = γ·R_f·T_d
2
+ made executable: each packet contains ONLY the task, the spec excerpt that
3
+ governs it, the exact file list, a constitution digest, and the verify
4
+ command — under a hard token budget. One packet == one agent session."""
5
+ from __future__ import annotations
6
+ import hashlib, re
7
+ from pathlib import Path
8
+
9
+ TOKEN_BUDGET = 2200 # ~ chars/4; hard cap for a packet
10
+ CHARS_PER_TOKEN = 4
11
+
12
+ def _est_tokens(s: str) -> int:
13
+ return len(s) // CHARS_PER_TOKEN
14
+
15
+ def _relevant_spec_excerpt(spec_text: str, task_text: str, max_chars: int = 2600) -> str:
16
+ """Requirement-scoped excerpt (anti-drift).
17
+
18
+ The old version matched individual LINES by shared vocabulary, which could
19
+ hand the agent a half-requirement (condition stripped off), forcing it to
20
+ improvise the rest. This version selects WHOLE requirement blocks and always
21
+ ships the complete acceptance scenario intact. The agent never receives a
22
+ partial rule.
23
+ """
24
+ words = {w.lower() for w in re.findall(r"[A-Za-z]{4,}", task_text)}
25
+ lines = spec_text.splitlines()
26
+
27
+ gherkin_block, in_g = [], False
28
+ for line in lines:
29
+ if line.strip().startswith("```gherkin"):
30
+ in_g = True
31
+ if in_g:
32
+ gherkin_block.append(line)
33
+ if in_g and line.strip() == "```" and not line.strip().startswith("```g"):
34
+ in_g = False
35
+
36
+ req_block, i = [], 0
37
+ while i < len(lines):
38
+ line = lines[i]
39
+ if line.lstrip().startswith("- "):
40
+ block = [line]; j = i + 1
41
+ while j < len(lines) and lines[j].startswith((" ", "\t")) and not lines[j].lstrip().startswith("- "):
42
+ block.append(lines[j]); j += 1
43
+ bw = {w.lower() for w in re.findall(r"[A-Za-z]{4,}", " ".join(block))}
44
+ if words & bw:
45
+ req_block.extend(block)
46
+ i = j
47
+ else:
48
+ i += 1
49
+
50
+ parts = []
51
+ if req_block:
52
+ parts.append("### Governing requirements (complete blocks)\n" + "\n".join(req_block))
53
+ if gherkin_block:
54
+ parts.append("### Acceptance (verbatim — do not reinterpret)\n" + "\n".join(gherkin_block))
55
+ out = "\n\n".join(parts) if parts else "\n".join(l for l in lines if l.lstrip().startswith("- "))
56
+ if len(out) > max_chars:
57
+ kept, total = [], 0
58
+ for para in out.split("\n\n"):
59
+ if total + len(para) > max_chars:
60
+ break
61
+ kept.append(para); total += len(para) + 2
62
+ out = "\n\n".join(kept) if kept else out[:max_chars]
63
+ return out
64
+
65
+ CONSTITUTION_DIGEST = (
66
+ "CONSTITUTION DIGEST: one task only; read ONLY listed files; tests ship with code; "
67
+ "never touch skeleton/; never add deps without ADR; never leave stubs; "
68
+ "decision logic goes to the factory, not inline; stop and ask on ambiguity."
69
+ )
70
+
71
+ def build_packet(root: Path, feature: str, task: dict) -> tuple[Path, dict]:
72
+ root = Path(root)
73
+ spec_text = (root/"specs"/f"{feature}.md").read_text()
74
+ excerpt = _relevant_spec_excerpt(spec_text, task["text"])
75
+ files_block = "\n".join(f"- {f}" for f in task["files"]) or "- (new files in slice)"
76
+ body = f"""# TASK PACKET {task['id']} — {feature}
77
+ <!-- One session. Finish, verify, stop. Context resets after this. -->
78
+
79
+ ## Your single task
80
+ {task['text']}
81
+
82
+ ## Slice (you may only create/modify files here + tests)
83
+ {task['slice']}
84
+
85
+ ## Files in scope (R_f — read nothing else except context/PROGRESS.md)
86
+ {files_block}
87
+
88
+ ## Governing spec excerpt
89
+ {excerpt}
90
+
91
+ ## {CONSTITUTION_DIGEST}
92
+
93
+ ## Definition of done
94
+ Run: `{task['verify']}` — must pass. Then STOP and report the diff summary.
95
+ """
96
+ est = _est_tokens(body)
97
+ if est > TOKEN_BUDGET:
98
+ # deterministic prune: shrink excerpt first (same discipline as HSF context assembler)
99
+ overflow = (est - TOKEN_BUDGET) * CHARS_PER_TOKEN
100
+ body = body.replace(excerpt, excerpt[: max(len(excerpt) - overflow, 400)])
101
+ est = _est_tokens(body)
102
+ out = root/"packets"/f"{feature}-{task['id']}.md"
103
+ out.parent.mkdir(exist_ok=True)
104
+ out.write_text(body)
105
+ meta = {"packet": str(out), "tokens_est": est,
106
+ "sha": hashlib.sha256(body.encode()).hexdigest()[:16]}
107
+ return out, meta
specline/paths.py ADDED
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+
4
+ def templates_dir() -> Path:
5
+ return Path(__file__).resolve().parents[1] / "templates"
6
+
7
+ def project_dirs(root: Path) -> dict:
8
+ return {"specs": root/"specs", "plans": root/"plans", "context": root/"context",
9
+ "adr": root/"adr", "packets": root/"packets", "slices": root/"slices",
10
+ "skeleton": root/"skeleton", "handoff": root/"handoff", "receipts": root/"receipts"}
specline/plan_lint.py ADDED
@@ -0,0 +1,38 @@
1
+ """specline tasks — atomicity linter. Enforces the double-decomposition
2
+ contract so every task is a clean Ralph-Wiggum session."""
3
+ from __future__ import annotations
4
+ import re
5
+ from pathlib import Path
6
+
7
+ TASK = re.compile(r"^- \[( |x)\] (T\d+) \| slice=(\S+) \| files=([^|]+) \| verify=`([^`]+)` \| (.+)$")
8
+
9
+ def parse_tasks(path: Path) -> list[dict]:
10
+ tasks = []
11
+ for line in Path(path).read_text().splitlines():
12
+ m = TASK.match(line.strip())
13
+ if m:
14
+ files = [f.strip() for f in m.group(4).split(",") if f.strip() and "<=" not in f]
15
+ tasks.append({"done": m.group(1) == "x", "id": m.group(2), "slice": m.group(3),
16
+ "files": files, "verify": m.group(5), "text": m.group(6)})
17
+ return tasks
18
+
19
+ def lint_plan(path: Path) -> tuple[list[dict], list[str]]:
20
+ text = Path(path).read_text()
21
+ tasks = parse_tasks(path)
22
+ errors = []
23
+ if not tasks:
24
+ errors.append("E_NO_TASKS: no parseable task lines (format: - [ ] T1 | slice=x | files=a,b | verify=`cmd` | text)")
25
+ if "Architect verdict: PASS" not in text:
26
+ errors.append("E_NO_VERDICT: plan lacks 'Architect verdict: PASS'")
27
+ ids = [t["id"] for t in tasks]
28
+ if len(ids) != len(set(ids)):
29
+ errors.append("E_DUP_TASK_ID")
30
+ for t in tasks:
31
+ if len(t["files"]) > 4:
32
+ errors.append(f"E_TASK_TOO_WIDE: {t['id']} touches {len(t['files'])} files (max 4)")
33
+ if t["slice"].startswith("skeleton"):
34
+ errors.append(f"E_SKELETON_TASK: {t['id']} targets human-owned skeleton")
35
+ for f in t["files"]:
36
+ if f and not (f.startswith(t["slice"]) or f.startswith("tests")):
37
+ errors.append(f"E_SLICE_ESCAPE: {t['id']} file {f!r} outside slice {t['slice']!r}")
38
+ return tasks, errors
specline/scaffold.py ADDED
@@ -0,0 +1,40 @@
1
+ """specline init — stand up the six-file context system + constitution."""
2
+ from __future__ import annotations
3
+ import shutil
4
+ from pathlib import Path
5
+ from .paths import templates_dir, project_dirs
6
+
7
+ def init_project(root: Path) -> list[Path]:
8
+ root = Path(root); created = []
9
+ for d in project_dirs(root).values():
10
+ d.mkdir(parents=True, exist_ok=True)
11
+ t = templates_dir()
12
+ for name in ["PROJECT_OVERVIEW.md","ARCHITECTURE.md","CODE_STANDARDS.md",
13
+ "AI_WORKFLOW_RULES.md","UI_CONTEXT.md","PROGRESS.md"]:
14
+ dst = root/"context"/name
15
+ if not dst.exists():
16
+ shutil.copy(t/"context"/name, dst); created.append(dst)
17
+ for src, dst in [(t/"AGENTS.md", root/"AGENTS.md")]:
18
+ if not dst.exists():
19
+ shutil.copy(src, dst); created.append(dst)
20
+ pd = root/"templates"
21
+ pd.mkdir(exist_ok=True)
22
+ for name in ["SPEC_TEMPLATE.md","PLAN_TEMPLATE.md"]:
23
+ if not (pd/name).exists():
24
+ shutil.copy(t/name, pd/name); created.append(pd/name)
25
+ per = root/"personas"; per.mkdir(exist_ok=True)
26
+ for p in (t/"personas").glob("*.md"):
27
+ if not (per/p.name).exists():
28
+ shutil.copy(p, per/p.name); created.append(per/p.name)
29
+ return created
30
+
31
+ def new_feature(root: Path, name: str) -> tuple[Path, Path]:
32
+ root = Path(root)
33
+ spec = root/"specs"/f"{name}.md"; plan = root/"plans"/f"{name}.md"
34
+ if spec.exists():
35
+ raise FileExistsError(spec)
36
+ spec.parent.mkdir(parents=True, exist_ok=True); plan.parent.mkdir(parents=True, exist_ok=True)
37
+ t = templates_dir()
38
+ spec.write_text((t/"SPEC_TEMPLATE.md").read_text().replace("{name}", name))
39
+ plan.write_text((t/"PLAN_TEMPLATE.md").read_text().replace("{name}", name))
40
+ return spec, plan
specline/spec_lint.py ADDED
@@ -0,0 +1,49 @@
1
+ """specline validate — EARS/Gherkin/leak lint. A spec that fails here never
2
+ reaches an agent, so ambiguity dies before it costs tokens."""
3
+ from __future__ import annotations
4
+ import re
5
+ from pathlib import Path
6
+
7
+ EARS = re.compile(r"^\s*-\s+(The system shall|When\b|While\b|If\b|Where\b)", re.M)
8
+ REQ_LINE = re.compile(r"^\s*-\s+\S", re.M)
9
+
10
+
11
+ def validate_spec(path: Path) -> list[str]:
12
+ text = Path(path).read_text()
13
+ errors = []
14
+ if "## MUST" not in text:
15
+ errors.append("E_NO_MUST: missing '## MUST — Functional core' section")
16
+ must = text.split("## SHOULD")[0]
17
+ reqs_section = must.split("### Requirements")[-1] if "### Requirements" in must else ""
18
+ req_lines = [l for l in REQ_LINE.findall(reqs_section)]
19
+ ears_hits = EARS.findall(reqs_section)
20
+ if not ears_hits:
21
+ errors.append("E_NO_EARS: no requirement uses an EARS keyword (shall/When/While/If/Where)")
22
+ non_ears = max(len(req_lines) - len(ears_hits), 0)
23
+ if req_lines and non_ears > len(req_lines) // 2:
24
+ errors.append(f"E_EARS_RATIO: {non_ears}/{len(req_lines)} requirement lines lack EARS keywords")
25
+ if "```gherkin" not in text:
26
+ errors.append("E_NO_GHERKIN: no acceptance criteria scenario found")
27
+ elif not re.search(r"Given .+", text) or not re.search(r"Then .+", text):
28
+ errors.append("E_GHERKIN_INCOMPLETE: scenario missing Given/Then")
29
+ fence_langs = re.findall(r"```(\w+)", must)
30
+ if any(lang != "gherkin" for lang in fence_langs):
31
+ errors.append("E_IMPL_LEAK: implementation code fence inside MUST section (belongs in plan)")
32
+ if re.search(r"## MUST.*?\b(def |class |function\(|=>)", must, re.S):
33
+ errors.append("E_IMPL_LEAK: code-level detail in MUST section")
34
+ if "Status: approved" in text and errors:
35
+ errors.append("E_APPROVED_BUT_INVALID: spec marked approved while failing lint")
36
+ return errors
37
+
38
+ def decision_rows(path: Path) -> list[dict]:
39
+ """Parse the 'Decision logic (factory candidates)' table -> rules."""
40
+ text = Path(path).read_text()
41
+ if "## Decision logic" not in text:
42
+ return []
43
+ section = text.split("## Decision logic")[1]
44
+ rows = []
45
+ for line in section.splitlines():
46
+ m = re.match(r"\s*\|\s*(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$", line)
47
+ if m:
48
+ rows.append({"n": int(m.group(1)), "if": m.group(2), "then": m.group(3)})
49
+ return rows
specline/specfactor.py ADDED
@@ -0,0 +1,21 @@
1
+ """SpecFactor gauge = spec lines / code lines, with the Goldilocks bands."""
2
+ from __future__ import annotations
3
+ from pathlib import Path
4
+
5
+ def specfactor(root: Path) -> dict:
6
+ root = Path(root)
7
+ spec_lines = sum(len(p.read_text().splitlines()) for p in (root/"specs").glob("*.md")) if (root/"specs").exists() else 0
8
+ plan_lines = sum(len(p.read_text().splitlines()) for p in (root/"plans").glob("*.md")) if (root/"plans").exists() else 0
9
+ code_lines = 0
10
+ for d in ["slices", "skeleton"]:
11
+ if (root/d).exists():
12
+ for p in (root/d).rglob("*.*"):
13
+ try: code_lines += len(p.read_text().splitlines())
14
+ except Exception: pass
15
+ ratio = round((spec_lines + plan_lines) / code_lines, 2) if code_lines else float("inf")
16
+ if code_lines == 0: verdict = "no code yet"
17
+ elif ratio < 0.5: verdict = "UNDER-SPECIFIED: sliding toward vibe coding — expect hallucinations"
18
+ elif ratio <= 2.5: verdict = "Goldilocks zone"
19
+ else: verdict = "OVER-SPECIFIED: review bottleneck risk (>2.5)"
20
+ return {"spec_lines": spec_lines + plan_lines, "code_lines": code_lines,
21
+ "specfactor": ratio, "verdict": verdict}