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.
- code_factory_1_spec-0.3.0.dist-info/METADATA +199 -0
- code_factory_1_spec-0.3.0.dist-info/RECORD +33 -0
- code_factory_1_spec-0.3.0.dist-info/WHEEL +5 -0
- code_factory_1_spec-0.3.0.dist-info/entry_points.txt +2 -0
- code_factory_1_spec-0.3.0.dist-info/licenses/LICENSE +21 -0
- code_factory_1_spec-0.3.0.dist-info/top_level.txt +1 -0
- specline/__init__.py +16 -0
- specline/adapters.py +32 -0
- specline/attribution.py +60 -0
- specline/cli.py +136 -0
- specline/drift_audit.py +150 -0
- specline/gates.py +40 -0
- specline/handoff.py +44 -0
- specline/ledger.py +40 -0
- specline/loop.py +66 -0
- specline/packets.py +107 -0
- specline/paths.py +10 -0
- specline/plan_lint.py +38 -0
- specline/scaffold.py +40 -0
- specline/spec_lint.py +49 -0
- specline/specfactor.py +21 -0
- specline/strict_lint.py +308 -0
- templates/AGENTS.md +22 -0
- templates/PLAN_TEMPLATE.md +11 -0
- templates/SPEC_TEMPLATE.md +38 -0
- templates/context/AI_WORKFLOW_RULES.md +6 -0
- templates/context/ARCHITECTURE.md +12 -0
- templates/context/CODE_STANDARDS.md +6 -0
- templates/context/PROGRESS.md +7 -0
- templates/context/PROJECT_OVERVIEW.md +10 -0
- templates/context/UI_CONTEXT.md +4 -0
- templates/personas/reviewer.md +6 -0
- templates/personas/security_auditor.md +5 -0
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}
|