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
|
@@ -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,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
|
specline/attribution.py
ADDED
|
@@ -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()
|
specline/drift_audit.py
ADDED
|
@@ -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]
|