devague 0.3.2__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.
- devague/__init__.py +11 -0
- devague/__main__.py +8 -0
- devague/cli/__init__.py +123 -0
- devague/cli/_commands/__init__.py +0 -0
- devague/cli/_commands/capture.py +31 -0
- devague/cli/_commands/confirm.py +38 -0
- devague/cli/_commands/converge.py +37 -0
- devague/cli/_commands/explain.py +31 -0
- devague/cli/_commands/export.py +49 -0
- devague/cli/_commands/interrogate.py +66 -0
- devague/cli/_commands/learn.py +88 -0
- devague/cli/_commands/list_frames.py +27 -0
- devague/cli/_commands/new.py +33 -0
- devague/cli/_commands/park.py +31 -0
- devague/cli/_commands/reject.py +19 -0
- devague/cli/_commands/show.py +27 -0
- devague/cli/_errors.py +39 -0
- devague/cli/_frames.py +29 -0
- devague/cli/_output.py +45 -0
- devague/convergence.py +72 -0
- devague/frame.py +184 -0
- devague/render/__init__.py +39 -0
- devague/render/frame_md.py +61 -0
- devague/render/spec_md.py +48 -0
- devague/store.py +88 -0
- devague-0.3.2.dist-info/METADATA +23 -0
- devague-0.3.2.dist-info/RECORD +30 -0
- devague-0.3.2.dist-info/WHEEL +4 -0
- devague-0.3.2.dist-info/entry_points.txt +2 -0
- devague-0.3.2.dist-info/licenses/LICENSE +21 -0
devague/cli/_errors.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""DevagueError and exit-code policy.
|
|
2
|
+
|
|
3
|
+
Every failure inside devague raises :class:`DevagueError`. The top-level
|
|
4
|
+
``main()`` catches it, formats via :mod:`devague.cli._output`, and exits with
|
|
5
|
+
:attr:`DevagueError.code`. This centralises the exit-code policy and
|
|
6
|
+
guarantees no Python traceback leaks to stderr.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
# Exit-code policy:
|
|
14
|
+
# 0 = success
|
|
15
|
+
# 1 = user-input error (bad flag, missing required arg, unknown path)
|
|
16
|
+
# 2 = environment / setup error (tool not installed, file unreadable)
|
|
17
|
+
# 3+ = reserved for future categorisation
|
|
18
|
+
EXIT_SUCCESS = 0
|
|
19
|
+
EXIT_USER_ERROR = 1
|
|
20
|
+
EXIT_ENV_ERROR = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class DevagueError(Exception):
|
|
25
|
+
"""Structured error raised within devague; carries a remediation hint."""
|
|
26
|
+
|
|
27
|
+
code: int
|
|
28
|
+
message: str
|
|
29
|
+
remediation: str = ""
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
super().__init__(self.message)
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, object]:
|
|
35
|
+
return {
|
|
36
|
+
"code": self.code,
|
|
37
|
+
"message": self.message,
|
|
38
|
+
"remediation": self.remediation,
|
|
39
|
+
}
|
devague/cli/_frames.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Resolve the target frame for a move: explicit --frame, else the current frame."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from devague import store
|
|
6
|
+
from devague.cli._errors import EXIT_USER_ERROR, DevagueError
|
|
7
|
+
from devague.frame import Frame
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve(slug: str | None) -> Frame:
|
|
11
|
+
slug = slug or store.current_slug()
|
|
12
|
+
if not slug:
|
|
13
|
+
raise DevagueError(
|
|
14
|
+
EXIT_USER_ERROR,
|
|
15
|
+
"no frame selected",
|
|
16
|
+
"run 'devague new \"<announcement>\"' or pass --frame <slug>",
|
|
17
|
+
)
|
|
18
|
+
try:
|
|
19
|
+
return store.load(slug)
|
|
20
|
+
except ValueError as exc:
|
|
21
|
+
raise DevagueError(
|
|
22
|
+
EXIT_USER_ERROR,
|
|
23
|
+
f"invalid frame slug: {slug!r}",
|
|
24
|
+
"slugs are lowercase letters, digits, and hyphens — no path separators",
|
|
25
|
+
) from exc
|
|
26
|
+
except FileNotFoundError:
|
|
27
|
+
raise DevagueError(
|
|
28
|
+
EXIT_USER_ERROR, f"no such frame: {slug}", "run 'devague list' to see frames"
|
|
29
|
+
) from None
|
devague/cli/_output.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""stdout / stderr helpers with a strict split.
|
|
2
|
+
|
|
3
|
+
Rule: **results go to stdout, diagnostics and errors go to stderr.** Agents
|
|
4
|
+
parsing devague output can rely on this invariant. JSON mode routes structured
|
|
5
|
+
payloads to the same streams — it never mixes them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any, TextIO
|
|
13
|
+
|
|
14
|
+
from devague.cli._errors import DevagueError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def emit_result(data: Any, *, json_mode: bool, stream: TextIO | None = None) -> None:
|
|
18
|
+
"""Write a command result to stdout (text or JSON), newline-terminated."""
|
|
19
|
+
s = stream if stream is not None else sys.stdout
|
|
20
|
+
if json_mode:
|
|
21
|
+
json.dump(data, s, ensure_ascii=False)
|
|
22
|
+
s.write("\n")
|
|
23
|
+
return
|
|
24
|
+
text = data if isinstance(data, str) else str(data)
|
|
25
|
+
s.write(text)
|
|
26
|
+
if not text.endswith("\n"):
|
|
27
|
+
s.write("\n")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def emit_error(err: DevagueError, *, json_mode: bool, stream: TextIO | None = None) -> None:
|
|
31
|
+
"""Write a :class:`DevagueError` to stderr (text or JSON)."""
|
|
32
|
+
s = stream if stream is not None else sys.stderr
|
|
33
|
+
if json_mode:
|
|
34
|
+
json.dump(err.to_dict(), s, ensure_ascii=False)
|
|
35
|
+
s.write("\n")
|
|
36
|
+
return
|
|
37
|
+
s.write(f"error: {err.message}\n")
|
|
38
|
+
if err.remediation:
|
|
39
|
+
s.write(f"hint: {err.remediation}\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def emit_diagnostic(message: str, *, stream: TextIO | None = None) -> None:
|
|
43
|
+
"""Write a human diagnostic (progress, summary) to stderr."""
|
|
44
|
+
s = stream if stream is not None else sys.stderr
|
|
45
|
+
s.write(message if message.endswith("\n") else message + "\n")
|
devague/convergence.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The convergence gate: is a frame solid enough to export a buildable spec?"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from devague.frame import SPEC_AFFECTING_KINDS, Frame
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ConvergenceResult:
|
|
12
|
+
passed: bool
|
|
13
|
+
missing: list[str] = field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _missing_required_kinds(confirmed_kinds: set[str]) -> list[str]:
|
|
17
|
+
"""Required confirmed claims for an honest announcement frame."""
|
|
18
|
+
missing = [
|
|
19
|
+
f"missing confirmed '{required}' claim"
|
|
20
|
+
for required in ("announcement", "audience", "after_state")
|
|
21
|
+
if required not in confirmed_kinds
|
|
22
|
+
]
|
|
23
|
+
if "before_state" not in confirmed_kinds and "why_it_matters" not in confirmed_kinds:
|
|
24
|
+
missing.append("missing 'before_state' or 'why_it_matters' claim")
|
|
25
|
+
if "boundary" not in confirmed_kinds:
|
|
26
|
+
missing.append("missing a 'boundary' / non-goal claim")
|
|
27
|
+
if "success_signal" not in confirmed_kinds:
|
|
28
|
+
missing.append("missing a 'success_signal' claim")
|
|
29
|
+
return missing
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _missing_claim_resolution(frame: Frame, confirmed: list) -> list[str]:
|
|
33
|
+
"""No spec-affecting claim left proposed; each confirmed one is pressure-tested."""
|
|
34
|
+
missing = [
|
|
35
|
+
f"claim {c.id} still proposed (confirm or reject it)"
|
|
36
|
+
for c in frame.claims
|
|
37
|
+
if c.kind in SPEC_AFFECTING_KINDS and c.status == "proposed"
|
|
38
|
+
]
|
|
39
|
+
missing += [
|
|
40
|
+
f"claim {c.id} has no confirmed honesty condition"
|
|
41
|
+
for c in confirmed
|
|
42
|
+
if c.kind in SPEC_AFFECTING_KINDS
|
|
43
|
+
and not any(h.status == "confirmed" for h in c.honesty_conditions)
|
|
44
|
+
]
|
|
45
|
+
return missing
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _missing_open_uncertainty(frame: Frame) -> list[str]:
|
|
49
|
+
"""No blocking vagueness or unresolved blocking hard question remains."""
|
|
50
|
+
missing = [
|
|
51
|
+
f"blocking vagueness {v.id} unresolved"
|
|
52
|
+
for v in frame.open_vagueness
|
|
53
|
+
if v.kind == "unknown_blocking"
|
|
54
|
+
]
|
|
55
|
+
missing += [
|
|
56
|
+
f"blocking hard question {q.id} on {c.id} unresolved"
|
|
57
|
+
for c in frame.claims
|
|
58
|
+
for q in c.hard_questions
|
|
59
|
+
if q.blocking and not q.resolved
|
|
60
|
+
]
|
|
61
|
+
return missing
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def evaluate(frame: Frame) -> ConvergenceResult:
|
|
65
|
+
confirmed = [c for c in frame.claims if c.status == "confirmed"]
|
|
66
|
+
confirmed_kinds = {c.kind for c in confirmed}
|
|
67
|
+
missing = (
|
|
68
|
+
_missing_required_kinds(confirmed_kinds)
|
|
69
|
+
+ _missing_claim_resolution(frame, confirmed)
|
|
70
|
+
+ _missing_open_uncertainty(frame)
|
|
71
|
+
)
|
|
72
|
+
return ConvergenceResult(passed=not missing, missing=missing)
|
devague/frame.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""The Frame domain model — claims, honesty conditions, hard questions, vagueness.
|
|
2
|
+
|
|
3
|
+
Pure data + transitions, no I/O. Persistence lives in :mod:`devague.store`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
CLAIM_KINDS = (
|
|
13
|
+
"announcement",
|
|
14
|
+
"audience",
|
|
15
|
+
"after_state",
|
|
16
|
+
"before_state",
|
|
17
|
+
"why_it_matters",
|
|
18
|
+
"boundary",
|
|
19
|
+
"success_signal",
|
|
20
|
+
"open_question",
|
|
21
|
+
)
|
|
22
|
+
SPEC_AFFECTING_KINDS = tuple(k for k in CLAIM_KINDS if k != "open_question")
|
|
23
|
+
VAGUENESS_KINDS = (
|
|
24
|
+
"unknown_nonblocking",
|
|
25
|
+
"unknown_blocking",
|
|
26
|
+
"out_of_scope",
|
|
27
|
+
"follow_up",
|
|
28
|
+
)
|
|
29
|
+
CLAIM_STATUSES = ("proposed", "confirmed", "rejected")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class HonestyCondition:
|
|
34
|
+
id: str
|
|
35
|
+
text: str
|
|
36
|
+
status: str = "proposed" # proposed | confirmed | rejected
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class HardQuestion:
|
|
41
|
+
id: str
|
|
42
|
+
text: str
|
|
43
|
+
resolved: bool = False
|
|
44
|
+
blocking: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Claim:
|
|
49
|
+
id: str
|
|
50
|
+
kind: str
|
|
51
|
+
text: str
|
|
52
|
+
origin: str = "user" # user | llm
|
|
53
|
+
status: str = "confirmed" # proposed | confirmed | rejected
|
|
54
|
+
honesty_conditions: list[HonestyCondition] = field(default_factory=list)
|
|
55
|
+
hard_questions: list[HardQuestion] = field(default_factory=list)
|
|
56
|
+
links: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Vagueness:
|
|
61
|
+
id: str
|
|
62
|
+
text: str
|
|
63
|
+
kind: str
|
|
64
|
+
claim_id: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Frame:
|
|
69
|
+
slug: str
|
|
70
|
+
title: str
|
|
71
|
+
status: str = "drafting" # drafting | converged | exported
|
|
72
|
+
created: str = ""
|
|
73
|
+
updated: str = ""
|
|
74
|
+
claims: list[Claim] = field(default_factory=list)
|
|
75
|
+
open_vagueness: list[Vagueness] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _next(items: list, prefix: str) -> str:
|
|
79
|
+
n = 0
|
|
80
|
+
for it in items:
|
|
81
|
+
if it.id.startswith(prefix):
|
|
82
|
+
try:
|
|
83
|
+
n = max(n, int(it.id[len(prefix) :]))
|
|
84
|
+
except ValueError:
|
|
85
|
+
pass
|
|
86
|
+
return f"{prefix}{n + 1}"
|
|
87
|
+
|
|
88
|
+
def _all_honesty(self) -> list[HonestyCondition]:
|
|
89
|
+
return [h for c in self.claims for h in c.honesty_conditions]
|
|
90
|
+
|
|
91
|
+
def _all_hard_questions(self) -> list[HardQuestion]:
|
|
92
|
+
return [q for c in self.claims for q in c.hard_questions]
|
|
93
|
+
|
|
94
|
+
def add_claim(self, kind: str, text: str, origin: str = "user") -> Claim:
|
|
95
|
+
if kind not in CLAIM_KINDS:
|
|
96
|
+
raise ValueError(f"unknown claim kind: {kind}")
|
|
97
|
+
status = "proposed" if origin == "llm" else "confirmed"
|
|
98
|
+
claim = Claim(
|
|
99
|
+
id=self._next(self.claims, "c"),
|
|
100
|
+
kind=kind,
|
|
101
|
+
text=text,
|
|
102
|
+
origin=origin,
|
|
103
|
+
status=status,
|
|
104
|
+
)
|
|
105
|
+
self.claims.append(claim)
|
|
106
|
+
return claim
|
|
107
|
+
|
|
108
|
+
def find_claim(self, cid: str) -> Optional[Claim]:
|
|
109
|
+
return next((c for c in self.claims if c.id == cid), None)
|
|
110
|
+
|
|
111
|
+
def find_honesty(self, hid: str) -> Optional[HonestyCondition]:
|
|
112
|
+
return next((h for h in self._all_honesty() if h.id == hid), None)
|
|
113
|
+
|
|
114
|
+
def add_honesty(self, claim: Claim, text: str, origin: str = "llm") -> HonestyCondition:
|
|
115
|
+
status = "confirmed" if origin == "user" else "proposed"
|
|
116
|
+
h = HonestyCondition(
|
|
117
|
+
id=self._next(self._all_honesty(), "h"),
|
|
118
|
+
text=text,
|
|
119
|
+
status=status,
|
|
120
|
+
)
|
|
121
|
+
claim.honesty_conditions.append(h)
|
|
122
|
+
return h
|
|
123
|
+
|
|
124
|
+
def add_hard_question(self, claim: Claim, text: str, blocking: bool = False) -> HardQuestion:
|
|
125
|
+
q = HardQuestion(
|
|
126
|
+
id=self._next(self._all_hard_questions(), "q"),
|
|
127
|
+
text=text,
|
|
128
|
+
blocking=blocking,
|
|
129
|
+
)
|
|
130
|
+
claim.hard_questions.append(q)
|
|
131
|
+
return q
|
|
132
|
+
|
|
133
|
+
def add_vagueness(self, text: str, kind: str, claim_id: Optional[str] = None) -> Vagueness:
|
|
134
|
+
if kind not in VAGUENESS_KINDS:
|
|
135
|
+
raise ValueError(f"unknown vagueness kind: {kind}")
|
|
136
|
+
v = Vagueness(
|
|
137
|
+
id=self._next(self.open_vagueness, "v"),
|
|
138
|
+
text=text,
|
|
139
|
+
kind=kind,
|
|
140
|
+
claim_id=claim_id,
|
|
141
|
+
)
|
|
142
|
+
self.open_vagueness.append(v)
|
|
143
|
+
return v
|
|
144
|
+
|
|
145
|
+
def set_status(self, item_id: str, status: str) -> bool:
|
|
146
|
+
claim = self.find_claim(item_id)
|
|
147
|
+
if claim is not None:
|
|
148
|
+
claim.status = status
|
|
149
|
+
return True
|
|
150
|
+
honesty = self.find_honesty(item_id)
|
|
151
|
+
if honesty is not None:
|
|
152
|
+
honesty.status = status
|
|
153
|
+
return True
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def to_dict(frame: Frame) -> dict:
|
|
158
|
+
return dataclasses.asdict(frame)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def from_dict(d: dict) -> Frame:
|
|
162
|
+
claims = [
|
|
163
|
+
Claim(
|
|
164
|
+
id=c["id"],
|
|
165
|
+
kind=c["kind"],
|
|
166
|
+
text=c["text"],
|
|
167
|
+
origin=c.get("origin", "user"),
|
|
168
|
+
status=c.get("status", "confirmed"),
|
|
169
|
+
honesty_conditions=[HonestyCondition(**h) for h in c.get("honesty_conditions", [])],
|
|
170
|
+
hard_questions=[HardQuestion(**q) for q in c.get("hard_questions", [])],
|
|
171
|
+
links=list(c.get("links", [])),
|
|
172
|
+
)
|
|
173
|
+
for c in d.get("claims", [])
|
|
174
|
+
]
|
|
175
|
+
vag = [Vagueness(**v) for v in d.get("open_vagueness", [])]
|
|
176
|
+
return Frame(
|
|
177
|
+
slug=d["slug"],
|
|
178
|
+
title=d["title"],
|
|
179
|
+
status=d.get("status", "drafting"),
|
|
180
|
+
created=d.get("created", ""),
|
|
181
|
+
updated=d.get("updated", ""),
|
|
182
|
+
claims=claims,
|
|
183
|
+
open_vagueness=vag,
|
|
184
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Renderer registry: frame/spec → text, selected by --format.
|
|
2
|
+
|
|
3
|
+
New output modalities (NotebookLM, HTML, user stories) register here.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from devague.cli._errors import EXIT_USER_ERROR, DevagueError
|
|
11
|
+
from devague.frame import Frame
|
|
12
|
+
|
|
13
|
+
_REGISTRY: dict[str, Callable[[Frame], str]] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register(name: str, fn: Callable[[Frame], str]) -> None:
|
|
17
|
+
_REGISTRY[name] = fn
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def formats() -> list[str]:
|
|
21
|
+
return sorted(_REGISTRY)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render(frame: Frame, fmt: str) -> str:
|
|
25
|
+
if fmt not in _REGISTRY:
|
|
26
|
+
raise DevagueError(
|
|
27
|
+
EXIT_USER_ERROR,
|
|
28
|
+
f"unknown format: {fmt}",
|
|
29
|
+
f"available formats: {', '.join(formats())}",
|
|
30
|
+
)
|
|
31
|
+
return _REGISTRY[fmt](frame)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Register the built-in renderers (import-time side effect).
|
|
35
|
+
from devague.render import frame_md as _frame_md # noqa: E402
|
|
36
|
+
from devague.render import spec_md as _spec_md # noqa: E402
|
|
37
|
+
|
|
38
|
+
register("frame-md", _frame_md.render_frame)
|
|
39
|
+
register("spec-md", _spec_md.render_spec)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Renderer: the Announcement Frame as markdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from devague.frame import Frame
|
|
6
|
+
|
|
7
|
+
_SECTIONS = [
|
|
8
|
+
("announcement", "Announcement"),
|
|
9
|
+
("audience", "Audience"),
|
|
10
|
+
("after_state", "After-state experience"),
|
|
11
|
+
("why_it_matters", "Why it matters"),
|
|
12
|
+
("before_state", "Before-state pain"),
|
|
13
|
+
("boundary", "Boundaries / non-goals"),
|
|
14
|
+
("success_signal", "Success signals"),
|
|
15
|
+
("open_question", "Open questions"),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _claim_lines(claim) -> list[str]:
|
|
20
|
+
mark = "" if claim.status == "confirmed" else f" _({claim.status})_"
|
|
21
|
+
lines = [f"- {claim.text}{mark}"]
|
|
22
|
+
for h in claim.honesty_conditions:
|
|
23
|
+
hm = "" if h.status == "confirmed" else f" _({h.status})_"
|
|
24
|
+
lines.append(f" - honesty: {h.text}{hm}")
|
|
25
|
+
for q in claim.hard_questions:
|
|
26
|
+
qm = "blocking" if q.blocking else "open"
|
|
27
|
+
lines.append(f" - Q ({qm}): {q.text}")
|
|
28
|
+
return lines
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _section_lines(frame: Frame, kind: str, heading: str) -> list[str]:
|
|
32
|
+
claims = [c for c in frame.claims if c.kind == kind and c.status != "rejected"]
|
|
33
|
+
if not claims:
|
|
34
|
+
return []
|
|
35
|
+
lines = [f"## {heading}"]
|
|
36
|
+
for c in claims:
|
|
37
|
+
lines.extend(_claim_lines(c))
|
|
38
|
+
lines.append("")
|
|
39
|
+
return lines
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _vagueness_lines(frame: Frame) -> list[str]:
|
|
43
|
+
if not frame.open_vagueness:
|
|
44
|
+
return []
|
|
45
|
+
lines = ["## Open vagueness"]
|
|
46
|
+
lines.extend(f"- [{v.kind}] {v.text}" for v in frame.open_vagueness)
|
|
47
|
+
lines.append("")
|
|
48
|
+
return lines
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_frame(frame: Frame) -> str:
|
|
52
|
+
out = [
|
|
53
|
+
f"# Announcement Frame — {frame.title}",
|
|
54
|
+
"",
|
|
55
|
+
f"_slug: {frame.slug} · status: {frame.status}_",
|
|
56
|
+
"",
|
|
57
|
+
]
|
|
58
|
+
for kind, heading in _SECTIONS:
|
|
59
|
+
out.extend(_section_lines(frame, kind, heading))
|
|
60
|
+
out.extend(_vagueness_lines(frame))
|
|
61
|
+
return "\n".join(out).rstrip() + "\n"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Renderer: the buildable spec as markdown, derived from a converged frame."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from devague.frame import Frame
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _texts(frame: Frame, kind: str) -> list[str]:
|
|
9
|
+
return [c.text for c in frame.claims if c.kind == kind and c.status == "confirmed"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_spec(frame: Frame) -> str:
|
|
13
|
+
out: list[str] = [f"# {frame.title}", ""]
|
|
14
|
+
ann = _texts(frame, "announcement")
|
|
15
|
+
if ann:
|
|
16
|
+
out += ["> " + ann[0], ""]
|
|
17
|
+
aud = _texts(frame, "audience")
|
|
18
|
+
if aud:
|
|
19
|
+
out += ["## Audience", *[f"- {t}" for t in aud], ""]
|
|
20
|
+
out += ["## Before → After", ""]
|
|
21
|
+
for t in _texts(frame, "before_state"):
|
|
22
|
+
out.append(f"- Before: {t}")
|
|
23
|
+
for t in _texts(frame, "after_state"):
|
|
24
|
+
out.append(f"- After: {t}")
|
|
25
|
+
out.append("")
|
|
26
|
+
why = _texts(frame, "why_it_matters")
|
|
27
|
+
if why:
|
|
28
|
+
out += ["## Why it matters", *[f"- {t}" for t in why], ""]
|
|
29
|
+
reqs = [h.text for c in frame.claims for h in c.honesty_conditions if h.status == "confirmed"]
|
|
30
|
+
if reqs:
|
|
31
|
+
out += ["## Requirements / honesty conditions", *[f"- {t}" for t in reqs], ""]
|
|
32
|
+
succ = _texts(frame, "success_signal")
|
|
33
|
+
if succ:
|
|
34
|
+
out += ["## Success signals", *[f"- {t}" for t in succ], ""]
|
|
35
|
+
bnd = _texts(frame, "boundary")
|
|
36
|
+
if bnd:
|
|
37
|
+
out += ["## Non-goals", *[f"- {t}" for t in bnd], ""]
|
|
38
|
+
hqs = [q for c in frame.claims for q in c.hard_questions]
|
|
39
|
+
if hqs:
|
|
40
|
+
out += [
|
|
41
|
+
"## Hard questions",
|
|
42
|
+
*[f"- {q.text}" + (" (blocking)" if q.blocking else "") for q in hqs],
|
|
43
|
+
"",
|
|
44
|
+
]
|
|
45
|
+
follow = [v.text for v in frame.open_vagueness if v.kind in ("follow_up", "out_of_scope")]
|
|
46
|
+
if follow:
|
|
47
|
+
out += ["## Open / follow-up", *[f"- {t}" for t in follow], ""]
|
|
48
|
+
return "\n".join(out).rstrip() + "\n"
|
devague/store.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Frame persistence: JSON under .devague/frames/, plus a current-frame pointer.
|
|
2
|
+
|
|
3
|
+
Paths are cwd-relative so the frames live in the repo being specced.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from devague.frame import Frame, from_dict, to_dict
|
|
14
|
+
|
|
15
|
+
FRAMES_DIR = Path(".devague/frames")
|
|
16
|
+
CURRENT = Path(".devague/current")
|
|
17
|
+
|
|
18
|
+
# A safe slug is a bounded, lowercase, hyphen-separated token with no path
|
|
19
|
+
# separators or `.` segments — so it can never escape FRAMES_DIR / SPECS_DIR.
|
|
20
|
+
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def slugify(title: str) -> str:
|
|
24
|
+
s = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
|
25
|
+
s = s[:50].strip("-")
|
|
26
|
+
return s or "frame"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_slug(slug: str) -> str:
|
|
30
|
+
"""Return ``slug`` if it is filesystem-safe, else raise ``ValueError``.
|
|
31
|
+
|
|
32
|
+
Guards every path built from a slug (``--frame``, ``.devague/current``, a
|
|
33
|
+
persisted ``frame.slug``) against path traversal and absolute paths.
|
|
34
|
+
"""
|
|
35
|
+
if not _SLUG_RE.fullmatch(slug or ""):
|
|
36
|
+
raise ValueError(f"invalid frame slug: {slug!r}")
|
|
37
|
+
return slug
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _now() -> str:
|
|
41
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def path_for(slug: str) -> Path:
|
|
45
|
+
return FRAMES_DIR / f"{validate_slug(slug)}.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def unique_slug(base: str) -> str:
|
|
49
|
+
"""Return ``base`` if free, else the first ``base-N`` (N>=2) that is unused."""
|
|
50
|
+
base = base or "frame"
|
|
51
|
+
if not path_for(base).exists():
|
|
52
|
+
return base
|
|
53
|
+
n = 2
|
|
54
|
+
while path_for(f"{base}-{n}").exists():
|
|
55
|
+
n += 1
|
|
56
|
+
return f"{base}-{n}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save(frame: Frame) -> Path:
|
|
60
|
+
FRAMES_DIR.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
frame.updated = _now()
|
|
62
|
+
if not frame.created:
|
|
63
|
+
frame.created = frame.updated
|
|
64
|
+
p = path_for(frame.slug)
|
|
65
|
+
p.write_text(json.dumps(to_dict(frame), indent=2) + "\n", encoding="utf-8")
|
|
66
|
+
CURRENT.write_text(frame.slug + "\n", encoding="utf-8")
|
|
67
|
+
return p
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load(slug: str) -> Frame:
|
|
71
|
+
p = path_for(slug)
|
|
72
|
+
if not p.exists():
|
|
73
|
+
raise FileNotFoundError(slug)
|
|
74
|
+
frame = from_dict(json.loads(p.read_text(encoding="utf-8")))
|
|
75
|
+
validate_slug(frame.slug) # reject a tampered file whose internal slug escapes
|
|
76
|
+
return frame
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_slugs() -> list[str]:
|
|
80
|
+
if not FRAMES_DIR.exists():
|
|
81
|
+
return []
|
|
82
|
+
return sorted(p.stem for p in FRAMES_DIR.glob("*.json"))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def current_slug() -> str | None:
|
|
86
|
+
if CURRENT.exists():
|
|
87
|
+
return CURRENT.read_text(encoding="utf-8").strip() or None
|
|
88
|
+
return None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devague
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: devague — turns a vague feature idea into a buildable spec by working backwards.
|
|
5
|
+
Project-URL: Homepage, https://github.com/agentculture/devague
|
|
6
|
+
Project-URL: Issues, https://github.com/agentculture/devague/issues
|
|
7
|
+
Author: AgentCulture
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Software Development
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# devague
|
|
19
|
+
|
|
20
|
+
An AgentCulture agent that turns a vague feature idea into a buildable spec by working backwards.
|
|
21
|
+
|
|
22
|
+
Greenfield: the CLI scaffold (`devague learn` / `explain`) ships as honest
|
|
23
|
+
"not yet implemented" stubs. See `CLAUDE.md` for project shape and commands.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
devague/__init__.py,sha256=IKSTqWc8OquRgC2y9E0TTv5CtNH3d2r82D2gLZa6hOQ,372
|
|
2
|
+
devague/__main__.py,sha256=wmTRpe3jqjUBqCm9NnpB2wO5V8U-gHJ5L7qBJMfCS_k,145
|
|
3
|
+
devague/convergence.py,sha256=mY-rh9eyd0halOzyoWefGqXk9yFGU1_cHnSG_Pq6B7Q,2532
|
|
4
|
+
devague/frame.py,sha256=jhBd3Sq2ChE2Ax_e-rlVavjSM1qvv-3R11hGPNPCc-g,5346
|
|
5
|
+
devague/store.py,sha256=XQ56DZwTgC_UuvAfhUiIKY9vsoTTDRVWVJc2ihztab0,2519
|
|
6
|
+
devague/cli/__init__.py,sha256=p5kdxD4IOBNuKVPOJOwF0-KxApfic-FLbPceOPN7my4,4370
|
|
7
|
+
devague/cli/_errors.py,sha256=VxfMpU-ZFv_-oXnxTaYpsCpn7CheS4ppwp3mrrSZh0k,1108
|
|
8
|
+
devague/cli/_frames.py,sha256=BMxgZftUeVs9gy2RTQ-1Jl1FJzpz6RJByF51qKffXU4,963
|
|
9
|
+
devague/cli/_output.py,sha256=JTtXNyd2MfIp77eguICh5ybZm2Y9xriZkYLti2Z_LcE,1547
|
|
10
|
+
devague/cli/_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
devague/cli/_commands/capture.py,sha256=lHlgVlAXt4MPP-SQJw5oRQ2u-A_UyATbpPWBZSMbSPQ,1234
|
|
12
|
+
devague/cli/_commands/confirm.py,sha256=YoiqJvy4czbGGvSoMKzDaiyq2g4V_hprREiuTFLzyw0,1323
|
|
13
|
+
devague/cli/_commands/converge.py,sha256=wIX9GkxIfb2h58UwBXJIE4H72Db6IYRC88e-9O5WL8I,1300
|
|
14
|
+
devague/cli/_commands/explain.py,sha256=gGSbBmGOhnjaLYhLth0XWbzklVb3ilukbkf6HwtY7yI,1042
|
|
15
|
+
devague/cli/_commands/export.py,sha256=lVWg-ZjCNogdFjBS85ckibOcWaGjydIAuo56ce3CMe8,1673
|
|
16
|
+
devague/cli/_commands/interrogate.py,sha256=J0g9I-3P1fepK4NIkrP54iy7GisPvrb-H-IIs9y3QQQ,2919
|
|
17
|
+
devague/cli/_commands/learn.py,sha256=u8-e6bmBd3_xZeM-MZ1JrLIf9NcoWCG2ej2dV85iW3w,3844
|
|
18
|
+
devague/cli/_commands/list_frames.py,sha256=rzM6FU9SY_rwxPaGhG-G3EVhmI0LVVNl4UiDNMBOyDA,861
|
|
19
|
+
devague/cli/_commands/new.py,sha256=wUgJeS-drCnhZ-LGpA2WXKsm70iN_cmltVloFsUMpX4,1257
|
|
20
|
+
devague/cli/_commands/park.py,sha256=CvdrDFzFgBOitTb-9xgOcFGoFgZxxXmR9riCkhhvdrk,1183
|
|
21
|
+
devague/cli/_commands/reject.py,sha256=pc051ZrCzfzBOGtJW9rLiBnSNSq8qir1g4KBrE9YikM,653
|
|
22
|
+
devague/cli/_commands/show.py,sha256=vIXoUmbruT8EDI7FrER514TGfSq2k3qGxcOPtLlt1Ls,960
|
|
23
|
+
devague/render/__init__.py,sha256=DosV7sVtrOmkYTGKFgH0oMQZtJ_XhJBj1g0n__ysYJY,1043
|
|
24
|
+
devague/render/frame_md.py,sha256=_-9xOfd50XbPBmdQGZCDilFObTnFkmW0JIb9qzFgxMI,1861
|
|
25
|
+
devague/render/spec_md.py,sha256=uSOGmhe-NpSg6592iFEu0iQyCfdF6nRtPiJ1llwF_3c,1801
|
|
26
|
+
devague-0.3.2.dist-info/METADATA,sha256=YpJrnFpu73ujN_mkpm5JUcWHcE6O0mdJKjNer-2wiZc,900
|
|
27
|
+
devague-0.3.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
28
|
+
devague-0.3.2.dist-info/entry_points.txt,sha256=M6Y_rebZwi29hI8d4nihXrv6j1gt-XNyFwXocwGUfLk,45
|
|
29
|
+
devague-0.3.2.dist-info/licenses/LICENSE,sha256=wCcdPywGtFXx1P8N0j0eEDINSWfSjrIsU7ds1YZl-MA,1069
|
|
30
|
+
devague-0.3.2.dist-info/RECORD,,
|