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.
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: code-factory-1-spec
3
+ Version: 0.3.0
4
+ Summary: SpecLine — a spec-driven production line for AI-assisted engineering: PRD -> spec -> plan -> atomic task packets -> gated code, with token-lean context hygiene and a compiled-decision handoff to Harness Software Factory (HSF).
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: PyYAML>=6.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == "dev"
12
+ Dynamic: license-file
13
+
14
+ # SpecLine 🏭
15
+
16
+ **A spec-driven production line for AI coding agents.** PRD → spec → plan →
17
+ atomic task packets → gated code → production, with token-lean context
18
+ hygiene enforced by tooling instead of discipline, and a compiled-decision
19
+ handoff to [Harness Software Factory](../harness-factory) for the logic that
20
+ should never be improvised twice.
21
+
22
+ Works with **Claude Code, Codex, and any agent harness** — one command wires it in.
23
+
24
+ ## Workflow at a glance
25
+
26
+ ```mermaid
27
+ flowchart LR
28
+ A["PRD or rough idea"] --> B["Draft spec"]
29
+ B --> C["Strict contract check"]
30
+ C -->|"blocks ambiguity"| B
31
+ C --> D["Gate spec and seal hash"]
32
+ D --> E["Create atomic plan"]
33
+ E --> F["Emit one task packet"]
34
+ F --> G["Agent builds exactly one slice"]
35
+ G --> H["Audit code against packet"]
36
+ H -->|"drift found"| F
37
+ H --> I["Gate code and update receipts"]
38
+ I --> J{"Decision table?"}
39
+ J -->|"yes"| K["Handoff to HSF"]
40
+ J -->|"no"| L["Ready for ForgeLine or PR"]
41
+ ```
42
+
43
+ ```
44
+ PRD ──> Spec (EARS+Gherkin) ──> Gate ──> Plan (atomic tasks) ──> Gate
45
+
46
+ ┌─────────────────────────────────────────┘
47
+
48
+ ┌── Ralph Wiggum Loop ──┐ Decision tables in the spec
49
+ │ specline loop next │ ──> specline handoff
50
+ │ → token-budgeted │ ──> HSF compiles them ONCE into
51
+ │ TASK PACKET │ gated, deterministic code
52
+ │ agent does ONE task │ (zero tokens per decision, forever)
53
+ │ specline loop done │
54
+ │ → verify + seal │
55
+ └──── context reset ────┘ ──> Gate ──> ship
56
+ ```
57
+
58
+ ## Why
59
+
60
+ Vibe coding hits the wall around four files: context pollution, intent
61
+ drift, API hallucinations. The fixes are known — specs as source of truth,
62
+ constitutions, vertical slices, context resets — but they live in blog
63
+ posts as *discipline*. SpecLine turns them into *tooling*: linted, gated,
64
+ hash-sealed, and receipt-audited, so the discipline holds at 2am too.
65
+
66
+ ## Quickstart (5 minutes, no API keys)
67
+
68
+ ```bash
69
+ pip install -e ".[dev]"
70
+ specline init # constitution + six-file context system
71
+ specline new refunds # spec + plan skeletons
72
+ # ... you + your agent fill the spec ...
73
+ specline validate refunds # EARS/Gherkin/leak lint — ambiguity dies here
74
+ specline gate spec refunds # hash-sealed human signoff
75
+ specline tasks refunds # atomicity lint: ≤4 files, one slice, verify cmd
76
+ specline gate plan refunds # locks the spec hash (drift guard arms)
77
+ specline loop next refunds # emits a token-budgeted TASK PACKET
78
+ # ... agent session does exactly one packet ...
79
+ specline loop done refunds T1 # runs verify command, seals receipt, advances
80
+ specline handoff refunds # decision table -> HSF workflow spec
81
+ specline agent claude # wires CLAUDE.md + /next-task command
82
+ specline status # token-savings receipt
83
+ pytest -q # 25 tests
84
+ ```
85
+
86
+ ## The mechanisms (what's actually enforced)
87
+
88
+ | Blog-post advice | SpecLine enforcement |
89
+ |---|---|
90
+ | "Write clear specs" | EARS keyword lint, Gherkin required, implementation-leak detection (`E_IMPL_LEAK`) |
91
+ | "Keep tasks small" | Atomicity linter: ≤4 files, one vertical slice, explicit verify command, no skeleton edits |
92
+ | "Reset agent context" | The loop emits self-contained **task packets** under a hard ~2.2k-token budget; one packet = one session |
93
+ | "Minimize context (C_t=γ·R_f·T_d)" | Packets list the exact R_f file set; excerpt only spec lines relevant to the task; deterministic prune over budget |
94
+ | "Prevent intent drift" | Plan gate seals the spec hash; if the spec changes, the loop **refuses** (`E_INTENT_DRIFT`) until re-gated |
95
+ | "Human review gates" | `specline gate spec|plan|code` writes hash-sealed signoff receipts to the progress tracker |
96
+ | "Don't let agents improvise business rules" | Decision tables compile through HSF: one-time generation, four gates, zero tokens per decision |
97
+ | "Measure the process" | SpecFactor gauge (Goldilocks 0.75–2.5) + a **context ledger**: packet tokens vs naive baseline, % saved |
98
+
99
+ ## Agent integration
100
+
101
+ - **Claude Code:** `specline agent claude` → writes `CLAUDE.md` (constitution +
102
+ protocol) and `.claude/commands/next-task.md`. The whole loop is one slash command.
103
+ - **Codex:** `specline agent codex` → appends the protocol to `AGENTS.md`
104
+ (Codex reads it natively).
105
+ - **Anything else:** `specline agent <name>` → portable constitution file.
106
+ The protocol is plain text; any harness that can read a file can follow it.
107
+
108
+ ## The factory calibration (the part that saves real money)
109
+
110
+ Most business logic in AI-built apps is *decision-shaped*: ordered rules over
111
+ extracted facts. Letting agents re-implement those rules inline is how you get
112
+ inconsistent behavior and burned tokens. SpecLine specs carry a
113
+ `## Decision logic` table; `specline handoff` converts it to a Harness
114
+ Software Factory spec, and HSF compiles it once into deterministic, gated,
115
+ signed code — verified end-to-end in this repo's test suite against a real
116
+ HSF install. App code flows through the line; decisions flow through the
117
+ factory; nothing is improvised twice.
118
+
119
+ ## Receipts culture
120
+
121
+ Every gate signoff, packet emission, and task completion writes a hash-sealed
122
+ line to `context/PROGRESS.md`, and the context ledger accumulates the token
123
+ economics (`specline status` — the walkthrough example shows ~75% saved vs
124
+ naive full-context sessions, and the gap widens as the repo grows). Claims
125
+ trace to receipts, never to vibes. That's the whole point.
126
+
127
+ MIT licensed.
128
+
129
+ ---
130
+
131
+ ## v0.2 — Strict Input Contract & Drift Audit
132
+
133
+ The base linter checks that a spec *looks* right (EARS keywords present, valid task
134
+ format). That's necessary but not sufficient: it lets **ambiguity** through, and the
135
+ AI coder then *invents* the missing parameters — which is drift. v0.2 closes that gap
136
+ with two new stages that bracket the coder.
137
+
138
+ ### `specline strict <feature>` — reject ambiguity *before* the coder runs
139
+
140
+ Treats the spec as a **contract the coder must execute with zero invention**. Every
141
+ finding is a BLOCK with an exact line and fix. It catches the five drift sources:
142
+
143
+ 1. **Incomplete requirements** — an EARS keyword isn't enough. Each requirement must
144
+ have a concrete outcome verb (`return`/`reject`/`store`/…), not `handle`/`support`/
145
+ `manage`. `The system shall handle it appropriately` is rejected.
146
+ 2. **Surviving placeholders** — `<trigger>`, `<N>`, `TBD` can't reach an approved spec.
147
+ 3. **Unquantified bounds** — a requirement that implies a timeout/limit/retry/size must
148
+ state a number+unit.
149
+ 4. **Untraceable acceptance** — every value in a Given/When/Then must be defined in a
150
+ requirement or the data model. A Gherkin step can't introduce a fact the coder would
151
+ have to invent.
152
+ 5. **Non-deterministic decisions** — each rule's `if` references a declared fact and its
153
+ `then` is exactly one outcome. No `maybe`/`or`/`etc`; no duplicate conditions.
154
+ (`else`/`default` catch-all rows are allowed.)
155
+
156
+ An `approved` spec that still fails strict raises `S_APPROVED_BUT_AMBIGUOUS` — approval
157
+ is a lie until the blocks are resolved.
158
+
159
+ Strict is **on by default** in `specline gate spec|plan`. Pass `strict=False` to the
160
+ gate API only for legacy specs.
161
+
162
+ ### `specline audit <feature> --files … --slice …` — catch drift *after* the coder runs
163
+
164
+ Compares what shipped against what the contract authorized:
165
+
166
+ - **`A_INVENTED_PARAM`** — a config value (`TIMEOUT = 45`) whose number the spec never
167
+ authorized. The coder guessed; the audit fails the build.
168
+ - **`A_SCOPE_ESCAPE`** — a file outside the task's authorized slice.
169
+ - **`A_UNAUTHORIZED_FILE`** — a file not in the packet's list.
170
+ - **`A_STUB_LEFT`** — a `TODO`/`NotImplementedError` left behind.
171
+
172
+ ### Requirement-scoped packets
173
+
174
+ The packet excerpt no longer bag-of-words-matches individual lines (which could hand the
175
+ agent half a requirement). It now ships **whole requirement blocks** and the **complete
176
+ acceptance scenario intact** — the agent never receives a partial rule to improvise around.
177
+
178
+ ### Flow
179
+
180
+ ```
181
+ new → write spec → validate → strict → gate spec → write plan → tasks → gate plan
182
+ → loop (build) → audit → gate code → handoff
183
+ ```
184
+
185
+ Deterministic by design: same spec text → same findings, every run. No LLM, no clock.
186
+ ## Failure attribution
187
+
188
+ SpecLine 0.3 reports strict-lint results per requirement and drift-audit results
189
+ per Python function. Failed units include a stable class such as
190
+ `ambiguous_requirement`, `untyped_input`, `invented_param`, or `scope_escape`,
191
+ plus the offending source phrase or code location. Existing pass/fail rules do
192
+ not change.
193
+
194
+ For machine-readable output:
195
+
196
+ ```bash
197
+ specline strict my_feature --json
198
+ specline audit my_feature --files slices/my_feature/logic.py --json
199
+ ```
@@ -0,0 +1,33 @@
1
+ code_factory_1_spec-0.3.0.dist-info/licenses/LICENSE,sha256=yrgL2BXp2X7HE1O6CSW1Y8loro0WundDyBFj5twonXU,1088
2
+ specline/__init__.py,sha256=n3z3vr1oFsBi0hRr1HA-iVUdVpOtzGzuKrnQiTLs_rU,706
3
+ specline/adapters.py,sha256=BF4Li55BkTB0S4TZ9zM9yfYXm4kUMj-7U4v25AomALo,1617
4
+ specline/attribution.py,sha256=1kexJ7Q8QmfHwrDbAq2NrWySruNSee91-uzsX3umBKM,1888
5
+ specline/cli.py,sha256=7WYTn61vB71H69v7Eg0JQ7RGCAWsWpP_3HTIGcM71ME,6893
6
+ specline/drift_audit.py,sha256=6idWpp1wgJ2Eg_j2_4MJZcKrFB7YitPa5MQaW8YFTEk,6579
7
+ specline/gates.py,sha256=BW5DHK3y_bF0s09mgsnl4mrK4Cs_VxDBlD5obajZCc0,1920
8
+ specline/handoff.py,sha256=XkhY_FEyQyVjPNn5r_IsjrOpoWEZjqfbASuUr-iqCoA,2354
9
+ specline/ledger.py,sha256=zN8yTG_37zxTs6TpZCD-ylK5b36v6KLLoF06Uz5hi5Y,1833
10
+ specline/loop.py,sha256=IfCUUz9AbK-VsD7IY16lFIDuxFW6b0QOv0PicO4m38c,3151
11
+ specline/packets.py,sha256=3KkcUoXVVGH2EZmOa6s-Y-oxW2oCL-Miie8AJinI6P0,4341
12
+ specline/paths.py,sha256=u_VoCXX9gxw2oFu-cWe28EXRqrFmU_0H-YrSd8Oub3w,465
13
+ specline/plan_lint.py,sha256=kLSyJwvkSZb_vzpYX788rAfe2gcp8_K8CY7kWEGsiBk,1821
14
+ specline/scaffold.py,sha256=nQX3AOhD4wShn4-da7mXoSABHZzK4qAeKNhfdD5-2Ns,1800
15
+ specline/spec_lint.py,sha256=-ech9dZjfgTH7wyGmdXLr_WOCJyhggAk1gZJzGp8P0I,2439
16
+ specline/specfactor.py,sha256=pD2l2Sw-Ixn5r_vp1Pv6NL5Gg-qZT7uuTKl4wEqBCGY,1197
17
+ specline/strict_lint.py,sha256=C2KpUAfNj-GdjYS6GqAfhUVGdr4ZAHIfmQzV5kQtJL0,14575
18
+ templates/AGENTS.md,sha256=X4Ygn4SYslNv8HfreMwu9g3Ap2XZ98Eq334KEigb-fw,1120
19
+ templates/PLAN_TEMPLATE.md,sha256=2L9V5UIT6UtXRBqym8TEjtZ-vm153_Wp4JKdzxnVjSM,396
20
+ templates/SPEC_TEMPLATE.md,sha256=yfRv2djQwgD7HY0vkeyxt0AirL0trgoxt3_4G4j8BkU,947
21
+ templates/context/AI_WORKFLOW_RULES.md,sha256=8551l9DA68XjkAy_UOeqRvQ8ZAlDnlpcWae-kMLhAoY,289
22
+ templates/context/ARCHITECTURE.md,sha256=exckCOT_eDEzvrZ00iND_lFYSkovMbhRQYuG3Yrq4kc,467
23
+ templates/context/CODE_STANDARDS.md,sha256=0RD_R0i3Qu9ipvkGdbrUlW1xHrJp7NawpwULwJypSIk,92
24
+ templates/context/PROGRESS.md,sha256=Q04lOJe_1QSp7X6ZE8Vc4mu2d-sQ50nM3lFCgGcCl08,123
25
+ templates/context/PROJECT_OVERVIEW.md,sha256=nlEE3BhMvSeTmYXsc2a8RIopOp__VPmgtNXkm009qGs,122
26
+ templates/context/UI_CONTEXT.md,sha256=RpKsV7k1EMJUDBJpMoOJWPJYWa33UeIHZUXYpkh8uLw,62
27
+ templates/personas/reviewer.md,sha256=fiFvz0C7enGcx9YVKRFDO3wNw-FIGVyK4q_UYPOt3tU,377
28
+ templates/personas/security_auditor.md,sha256=6_1y0caYLMJJDeSmduuH1JzvUuY-ttR1PCh34Drf1Ac,331
29
+ code_factory_1_spec-0.3.0.dist-info/METADATA,sha256=CAbzN8-XRreke3XwPFRrZ3L10sYWX5aI_dmgAbau78Y,9842
30
+ code_factory_1_spec-0.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
31
+ code_factory_1_spec-0.3.0.dist-info/entry_points.txt,sha256=u7onuG3BPSfKRCnvBzUpiOI16hO2S4fkkY3OTo_cY2g,47
32
+ code_factory_1_spec-0.3.0.dist-info/top_level.txt,sha256=c1gyltWWOPbRVaq6pW3E0oI3p7G_0X_qotHeOENv7X8,9
33
+ code_factory_1_spec-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ specline = specline.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WizeMe.APP
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ specline
specline/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """SpecLine — the spec-driven production line.
2
+
3
+ Vibe coding fails at the four-file mark; SpecLine replaces it with a
4
+ standardized, tool-agnostic operating procedure any coding agent
5
+ (Claude Code, Codex, others) can follow:
6
+
7
+ PRD -> Spec (EARS) -> Plan -> Atomic Tasks -> Task Packets (token-lean)
8
+ -> Ralph Wiggum execution loop -> Review Gates -> Production
9
+
10
+ Decision-shaped logic (ordered rules over extracted facts) doesn't go
11
+ through agents at all: `specline handoff` emits a Harness Software
12
+ Factory workflow spec so it can be compiled ONCE into gated,
13
+ deterministic code. Agents write tissue; humans own the skeleton;
14
+ the factory owns the decisions.
15
+ """
16
+ __version__ = "0.1.0"
specline/adapters.py ADDED
@@ -0,0 +1,32 @@
1
+ """Agent adapters — one command wires SpecLine into Claude Code, Codex, or
2
+ any agent harness. The constitution IS the agent config; packets are the
3
+ session protocol; slash commands make the loop one keystroke."""
4
+ from __future__ import annotations
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ CLAUDE_NEXT_CMD = """Read context/PROGRESS.md, then run `specline loop next <feature>`.
9
+ Open ONLY the packet file it prints and the files the packet lists.
10
+ Complete the packet's single task, run its verify command, then run
11
+ `specline loop done <feature> <task-id>` and STOP. Do not start another task.
12
+ """
13
+
14
+ def wire_agent(root: Path, agent: str) -> list[Path]:
15
+ root = Path(root); created = []
16
+ constitution = (root/"AGENTS.md").read_text()
17
+ if agent in {"claude", "claude-code"}:
18
+ dst = root/"CLAUDE.md"
19
+ dst.write_text(constitution + "\n\n## SpecLine protocol\n" + CLAUDE_NEXT_CMD)
20
+ created.append(dst)
21
+ cmd_dir = root/".claude"/"commands"; cmd_dir.mkdir(parents=True, exist_ok=True)
22
+ c = cmd_dir/"next-task.md"; c.write_text(CLAUDE_NEXT_CMD); created.append(c)
23
+ elif agent == "codex":
24
+ # Codex reads AGENTS.md natively; append the protocol if absent
25
+ if "SpecLine protocol" not in constitution:
26
+ (root/"AGENTS.md").write_text(constitution + "\n\n## SpecLine protocol\n" + CLAUDE_NEXT_CMD)
27
+ created.append(root/"AGENTS.md")
28
+ else:
29
+ dst = root/f"{agent.upper()}_AGENT.md"
30
+ dst.write_text(constitution + "\n\n## SpecLine protocol\n" + CLAUDE_NEXT_CMD)
31
+ created.append(dst)
32
+ return created
@@ -0,0 +1,60 @@
1
+ """Standalone copy of the factory attribution contract."""
2
+ from dataclasses import asdict, dataclass
3
+ from enum import Enum
4
+
5
+
6
+ class FailureClass(str, Enum):
7
+ AMBIGUOUS_REQUIREMENT = "ambiguous_requirement"
8
+ UNTYPED_INPUT = "untyped_input"
9
+ SCOPE_ESCAPE = "scope_escape"
10
+ INVENTED_PARAM = "invented_param"
11
+ SIGNATURE_DRIFT = "signature_drift"
12
+ STUB_UNFILLED = "stub_unfilled"
13
+ COMPLEXITY_EXCEEDED = "complexity_exceeded"
14
+ INCONSISTENT_LOGIC = "inconsistent_logic"
15
+ RUNTIME_CRASH = "runtime_crash"
16
+ RUNTIME_TIMEOUT = "runtime_timeout"
17
+ WRONG_OUTPUT = "wrong_output"
18
+ ACCURACY_REGRESSION = "accuracy_regression"
19
+ NONDETERMINISM = "nondeterminism"
20
+ SECURITY_FINDING = "security_finding"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class UnitResult:
25
+ unit: str
26
+ stage: str
27
+ passed: bool
28
+ evidence: str
29
+ failure_class: FailureClass | None = None
30
+
31
+ def __post_init__(self):
32
+ if not self.passed and (self.failure_class is None or not self.evidence.strip()):
33
+ raise ValueError("failed units require a class and concrete evidence")
34
+
35
+
36
+ @dataclass
37
+ class Attribution:
38
+ stage: str
39
+ n_checked: int
40
+ n_passed: int
41
+ units: list[UnitResult]
42
+
43
+ @property
44
+ def rate(self):
45
+ return self.n_passed / self.n_checked if self.n_checked else 0.0
46
+
47
+ def dominant_failure_class(self):
48
+ counts = {kind: 0 for kind in FailureClass}
49
+ for unit in self.units:
50
+ if not unit.passed:
51
+ counts[unit.failure_class] += 1
52
+ maximum = max(counts.values(), default=0)
53
+ return next((kind for kind in FailureClass if maximum and counts[kind] == maximum), None)
54
+
55
+ def to_dict(self):
56
+ value = asdict(self)
57
+ value["rate"] = self.rate
58
+ dominant = self.dominant_failure_class()
59
+ value["dominant_failure_class"] = dominant.value if dominant else None
60
+ return value
specline/cli.py ADDED
@@ -0,0 +1,136 @@
1
+ """specline CLI — the production line, end to end:
2
+ init -> new -> (write spec) -> validate -> gate spec -> (write plan) ->
3
+ tasks -> gate plan -> loop next/done ... -> gate code -> handoff -> status
4
+ """
5
+ from __future__ import annotations
6
+ import argparse, json
7
+ from pathlib import Path
8
+
9
+ def main(argv=None):
10
+ p = argparse.ArgumentParser(prog="specline", description="Spec-driven production line for AI coding agents")
11
+ sub = p.add_subparsers(required=True, dest="cmd")
12
+
13
+ s = sub.add_parser("init", help="scaffold constitution + six-file context system")
14
+ s.add_argument("--root", default=".")
15
+
16
+ s = sub.add_parser("new", help="create spec+plan skeleton for a feature")
17
+ s.add_argument("feature"); s.add_argument("--root", default=".")
18
+
19
+ s = sub.add_parser("validate", help="lint a spec (EARS/Gherkin/leaks)")
20
+ s.add_argument("feature"); s.add_argument("--root", default=".")
21
+
22
+ s = sub.add_parser("strict", help="STRICT semantic input contract — reject ambiguity before the coder")
23
+ s.add_argument("feature"); s.add_argument("--root", default=".")
24
+ s.add_argument("--warn", action="store_true", help="also show WARN-level smells")
25
+ s.add_argument("--json", action="store_true", help="emit machine-readable attribution")
26
+
27
+ s = sub.add_parser("audit", help="post-code drift audit (invented params / scope escape / stubs)")
28
+ s.add_argument("feature"); s.add_argument("--root", default=".")
29
+ s.add_argument("--files", nargs="+", required=True, help="changed files to audit")
30
+ s.add_argument("--slice", default=None, help="authorized slice prefix")
31
+ s.add_argument("--json", action="store_true", help="emit machine-readable attribution")
32
+
33
+ s = sub.add_parser("tasks", help="lint plan task atomicity")
34
+ s.add_argument("feature"); s.add_argument("--root", default=".")
35
+
36
+ s = sub.add_parser("gate", help="record a human gate signoff (spec|plan|code)")
37
+ s.add_argument("phase", choices=["spec", "plan", "code"]); s.add_argument("feature")
38
+ s.add_argument("--approver", default="human"); s.add_argument("--root", default=".")
39
+
40
+ s = sub.add_parser("loop", help="Ralph Wiggum loop: next | done")
41
+ s.add_argument("action", choices=["next", "done"]); s.add_argument("feature")
42
+ s.add_argument("task_id", nargs="?"); s.add_argument("--no-verify", action="store_true")
43
+ s.add_argument("--root", default=".")
44
+
45
+ s = sub.add_parser("handoff", help="emit HSF workflow spec from decision table")
46
+ s.add_argument("feature"); s.add_argument("--root", default=".")
47
+
48
+ s = sub.add_parser("agent", help="wire into an agent harness (claude|codex|<name>)")
49
+ s.add_argument("name"); s.add_argument("--root", default=".")
50
+
51
+ s = sub.add_parser("specfactor", help="spec/code ratio gauge")
52
+ s.add_argument("--root", default=".")
53
+
54
+ s = sub.add_parser("status", help="progress + token-savings receipt")
55
+ s.add_argument("--root", default=".")
56
+
57
+ a = p.parse_args(argv)
58
+ root = Path(a.root)
59
+
60
+ if a.cmd == "init":
61
+ from .scaffold import init_project
62
+ created = init_project(root)
63
+ print("created:\n " + "\n ".join(str(c) for c in created))
64
+ print("next: specline new <feature>")
65
+ elif a.cmd == "new":
66
+ from .scaffold import new_feature
67
+ spec, plan = new_feature(root, a.feature)
68
+ print(f"spec: {spec}\nplan: {plan}\nnext: fill the spec, then `specline validate {a.feature}`")
69
+ elif a.cmd == "validate":
70
+ from .spec_lint import validate_spec
71
+ errs = validate_spec(root/"specs"/f"{a.feature}.md")
72
+ if errs: raise SystemExit("INVALID:\n" + "\n".join(errs))
73
+ print("spec OK — next: specline strict " + a.feature)
74
+ elif a.cmd == "strict":
75
+ from .strict_lint import strict_validate
76
+ spec_path = root/"specs"/f"{a.feature}.md"
77
+ rep = strict_validate(spec_path)
78
+ if a.json:
79
+ print(json.dumps({"passed": rep.ok,
80
+ "attribution": rep.attribution(spec_path.read_text()).to_dict()},
81
+ indent=2))
82
+ for f in (rep.findings if a.warn else rep.blocks):
83
+ print(str(f))
84
+ if rep.blocks:
85
+ raise SystemExit(f"\nSTRICT FAILED: {len(rep.blocks)} blocking ambiguity(ies) — "
86
+ f"the coder would guess these. Fix before gating.")
87
+ print(f"STRICT OK — spec is unambiguous ({len(rep.warns)} warns). "
88
+ f"next: specline gate spec {a.feature}")
89
+ elif a.cmd == "audit":
90
+ from .drift_audit import audit_code_against_spec, audit_report_lines
91
+ rep = audit_code_against_spec(root/"specs"/f"{a.feature}.md",
92
+ [Path(f) for f in a.files], slice_prefix=a.slice)
93
+ if a.json:
94
+ print(json.dumps({"passed": rep.ok,
95
+ "attribution": rep.attribution([Path(f) for f in a.files]).to_dict()},
96
+ indent=2))
97
+ for line in audit_report_lines(rep):
98
+ print(line)
99
+ if rep.blocks:
100
+ raise SystemExit(f"\nAUDIT FAILED: {len(rep.blocks)} drift(s) detected.")
101
+ print(f"AUDIT OK — no drift ({len(rep.warns)} warns).")
102
+ elif a.cmd == "tasks":
103
+ from .plan_lint import lint_plan
104
+ tasks, errs = lint_plan(root/"plans"/f"{a.feature}.md")
105
+ if errs: raise SystemExit("PLAN INVALID:\n" + "\n".join(errs))
106
+ print(f"{len(tasks)} atomic tasks OK — next: specline gate plan " + a.feature)
107
+ elif a.cmd == "gate":
108
+ from .gates import gate
109
+ print(json.dumps(gate(root, a.phase, a.feature, a.approver)))
110
+ elif a.cmd == "loop":
111
+ from .loop import next_task, mark_done
112
+ if a.action == "next":
113
+ print(json.dumps(next_task(root, a.feature), indent=2))
114
+ else:
115
+ if not a.task_id: raise SystemExit("task_id required for done")
116
+ print(json.dumps(mark_done(root, a.feature, a.task_id, run_verify=not a.no_verify)))
117
+ elif a.cmd == "handoff":
118
+ from .handoff import handoff_to_hsf
119
+ out = handoff_to_hsf(root, a.feature)
120
+ print(f"HSF spec: {out}\ncompile with: hsf compile {out}")
121
+ elif a.cmd == "agent":
122
+ from .adapters import wire_agent
123
+ created = wire_agent(root, a.name)
124
+ print("wired:\n " + "\n ".join(str(c) for c in created))
125
+ elif a.cmd == "specfactor":
126
+ from .specfactor import specfactor
127
+ print(json.dumps(specfactor(root), indent=2))
128
+ elif a.cmd == "status":
129
+ from .ledger import summarize
130
+ s = summarize(root)
131
+ print(json.dumps(s, indent=2))
132
+ if s["sessions"]:
133
+ print(f"→ packets used {s['packet_tokens']:,} est. tokens vs {s['naive_tokens']:,} naive: {s['saved_pct']}% saved")
134
+
135
+ if __name__ == "__main__":
136
+ main()
@@ -0,0 +1,150 @@
1
+ """specline audit — the post-coding drift detector.
2
+
3
+ strict_lint stops ambiguity BEFORE the coder runs. This catches drift AFTER:
4
+ it compares what shipped against what the contract authorized, and fails when
5
+ the coder invented parameters, referenced undefined entities, or widened scope.
6
+
7
+ Deterministic: it parses, it does not judge intent.
8
+ """
9
+ from __future__ import annotations
10
+ import re
11
+ import ast
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from .strict_lint import _declared_terms, _requirement_lines, Finding
15
+
16
+ _MAGIC_NUMBER = re.compile(r"(?<![\w.])(\d+)(?![\w.])")
17
+ _PARAM_ASSIGN = re.compile(
18
+ r"\b([A-Z][A-Z0-9_]{2,})\s*=\s*([^\n#]+)"
19
+ r"|\b(timeout|retries|max_\w+|min_\w+|limit|threshold|ttl|batch_size|"
20
+ r"page_size|expiry|expires_in)\s*[:=]\s*([^\n#,)]+)", re.I)
21
+
22
+
23
+ @dataclass
24
+ class AuditReport:
25
+ findings: list[Finding] = field(default_factory=list)
26
+ @property
27
+ def blocks(self): return [f for f in self.findings if f.severity == "BLOCK"]
28
+ @property
29
+ def warns(self): return [f for f in self.findings if f.severity == "WARN"]
30
+ @property
31
+ def ok(self) -> bool: return not self.blocks
32
+ def add(self, code, severity, message, line=0):
33
+ self.findings.append(Finding(code, severity, message, line))
34
+
35
+ def attribution(self, changed_files):
36
+ from .attribution import Attribution, FailureClass, UnitResult
37
+ units = []
38
+ for file_path in changed_files:
39
+ path = Path(file_path)
40
+ path_text = str(path)
41
+ file_findings = [f for f in self.blocks if path_text in f.message]
42
+ spans = []
43
+ if path.exists() and path.suffix == ".py":
44
+ try:
45
+ tree = ast.parse(path.read_text())
46
+ spans = [
47
+ (node.name, node.lineno, getattr(node, "end_lineno", node.lineno))
48
+ for node in ast.walk(tree)
49
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
50
+ ]
51
+ except (SyntaxError, OSError, UnicodeDecodeError):
52
+ spans = []
53
+ if not spans:
54
+ spans = [("<module>", 1, 10**9)]
55
+ for function_name, start, end in sorted(spans, key=lambda item: (item[1], item[0])):
56
+ related = [
57
+ finding for finding in file_findings
58
+ if finding.line == 0 or start <= finding.line <= end
59
+ ]
60
+ failure_class = None
61
+ if related:
62
+ code = related[0].code
63
+ failure_class = (
64
+ FailureClass.INVENTED_PARAM if code == "A_INVENTED_PARAM"
65
+ else FailureClass.STUB_UNFILLED if code == "A_STUB_LEFT"
66
+ else FailureClass.SCOPE_ESCAPE
67
+ )
68
+ units.append(UnitResult(
69
+ unit=f"function:{path_text}:{function_name}",
70
+ stage="drift_audit",
71
+ passed=not related,
72
+ evidence="no drift found" if not related else related[0].message,
73
+ failure_class=failure_class,
74
+ ))
75
+ return Attribution("drift_audit", len(units), sum(u.passed for u in units), units)
76
+
77
+
78
+ def _spec_authorized_numbers(spec_text: str) -> set[str]:
79
+ return {m.group(1) for m in re.finditer(r"\b(\d+(?:\.\d+)?)\b", spec_text)}
80
+
81
+
82
+ def _in_slice(fp: Path, slice_prefix: str | None) -> bool:
83
+ """Segment-aware slice membership: an absolute path .../refund/logic.py is
84
+ inside slice 'refund'. This is what made the naive str.startswith unreliable."""
85
+ if slice_prefix is None:
86
+ return True
87
+ sp = slice_prefix.rstrip("/")
88
+ parts = fp.parts
89
+ if sp in parts:
90
+ return True
91
+ for i in range(len(parts)):
92
+ tail = "/".join(parts[i:])
93
+ if tail == sp or tail.startswith(sp + "/"):
94
+ return True
95
+ return False
96
+
97
+
98
+ def audit_code_against_spec(spec_path, changed_files, slice_prefix=None, packet_files=None):
99
+ rep = AuditReport()
100
+ spec_text = Path(spec_path).read_text()
101
+ ok_numbers = _spec_authorized_numbers(spec_text)
102
+ authorized = {str(f) for f in (packet_files or [])}
103
+
104
+ for fp in changed_files:
105
+ fp = Path(fp)
106
+ rel = str(fp)
107
+ # Only the FILENAME determines test-ness. The full path can contain
108
+ # 'test' incidentally (e.g. pytest temp dirs), which would wrongly
109
+ # suppress scope/param findings.
110
+ is_test = fp.name.lower().startswith("test") or fp.name.lower().endswith("_test.py") \
111
+ or "tests" in fp.parts
112
+
113
+ if slice_prefix and not _in_slice(fp, slice_prefix) and not is_test:
114
+ rep.add("A_SCOPE_ESCAPE", "BLOCK",
115
+ f"{rel} is outside the authorized slice {slice_prefix!r}. The coder widened "
116
+ f"scope beyond its task.")
117
+ if authorized and rel not in authorized and not is_test:
118
+ rep.add("A_UNAUTHORIZED_FILE", "BLOCK",
119
+ f"{rel} was not in the packet's file list — packet was wrong or coder invented "
120
+ f"a file.")
121
+
122
+ if not fp.exists() or fp.is_dir():
123
+ continue
124
+ try:
125
+ code = fp.read_text()
126
+ except (UnicodeDecodeError, OSError):
127
+ continue
128
+
129
+ for i, ln in enumerate(code.splitlines(), 1):
130
+ stripped = ln.strip()
131
+ if stripped.startswith(("#", "//", "*", "/*")):
132
+ continue
133
+ for m in _PARAM_ASSIGN.finditer(ln):
134
+ val = (m.group(2) or m.group(4) or "").strip()
135
+ for num in _MAGIC_NUMBER.findall(val):
136
+ if num not in ok_numbers:
137
+ name = (m.group(1) or m.group(3) or "param").strip()
138
+ rep.add("A_INVENTED_PARAM", "BLOCK",
139
+ f"{rel}:{i} sets {name}={val} but the spec never authorizes the "
140
+ f"value {num}. Invented parameter — add to spec or remove.", i)
141
+ break
142
+ if re.search(r"\b(TODO|FIXME|XXX|NotImplemented|raise NotImplementedError)\b", ln):
143
+ rep.add("A_STUB_LEFT", "BLOCK",
144
+ f"{rel}:{i} leaves a stub/TODO — constitution forbids shipping incomplete "
145
+ f"work.", i)
146
+ return rep
147
+
148
+
149
+ def audit_report_lines(rep: AuditReport) -> list[str]:
150
+ return [str(f) for f in rep.findings]