ostler 0.1.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.
- ostler/__init__.py +3 -0
- ostler/backlog.py +73 -0
- ostler/cli.py +307 -0
- ostler/crud.py +232 -0
- ostler/doctor.py +275 -0
- ostler/edit.py +154 -0
- ostler/freeze.py +116 -0
- ostler/ids.py +79 -0
- ostler/markdown.py +238 -0
- ostler/model.py +385 -0
- ostler/query.py +137 -0
- ostler/registry.py +118 -0
- ostler/schema/epic.schema.json +14 -0
- ostler/schema/feature.schema.json +16 -0
- ostler/schema/ids.schema.json +30 -0
- ostler/schema/knowledge-record.schema.json +36 -0
- ostler/schema/story.schema.json +14 -0
- ostler/schemas.py +27 -0
- ostler/scripts/__init__.py +0 -0
- ostler/scripts/okf_migrate.py +327 -0
- ostler/select.py +59 -0
- ostler/todo.py +81 -0
- ostler/trace.py +79 -0
- ostler-0.1.0.dist-info/METADATA +253 -0
- ostler-0.1.0.dist-info/RECORD +28 -0
- ostler-0.1.0.dist-info/WHEEL +4 -0
- ostler-0.1.0.dist-info/entry_points.txt +2 -0
- ostler-0.1.0.dist-info/licenses/LICENSE +21 -0
ostler/__init__.py
ADDED
ostler/backlog.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""`ostler backlog` — the intake list as managed markdown (``docs/backlog.md``).
|
|
2
|
+
|
|
3
|
+
Bullets are ``- [<id>] <text>`` optionally grouped under ``## <section>`` headings. Replaces the
|
|
4
|
+
former bespoke append/prune scripts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .crud import Result
|
|
13
|
+
from .model import Graph
|
|
14
|
+
|
|
15
|
+
_ITEM = re.compile(r"^\s*[-*]\s+\[(?P<id>[^\]]+)\]\s*(?P<text>.*)$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _path(graph: Graph) -> Path:
|
|
19
|
+
return graph.root / "docs" / "backlog.md"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def items(graph: Graph) -> list[tuple[str, str]]:
|
|
23
|
+
"""(id, text) pairs across all sections, in file order."""
|
|
24
|
+
p = _path(graph)
|
|
25
|
+
if not p.exists():
|
|
26
|
+
return []
|
|
27
|
+
out: list[tuple[str, str]] = []
|
|
28
|
+
for line in p.read_text(encoding="utf-8").splitlines():
|
|
29
|
+
m = _ITEM.match(line)
|
|
30
|
+
if m:
|
|
31
|
+
out.append((m.group("id").strip(), m.group("text").strip()))
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def add(graph: Graph, item_id: str, text: str, section: str = "") -> Result:
|
|
36
|
+
p = _path(graph)
|
|
37
|
+
existing = {i for i, _ in items(graph)}
|
|
38
|
+
if item_id in existing:
|
|
39
|
+
return Result(False, f"backlog item '{item_id}' already exists")
|
|
40
|
+
lines = p.read_text(encoding="utf-8").splitlines() if p.exists() else ["# Backlog", ""]
|
|
41
|
+
bullet = f"- [{item_id}] {text}".rstrip()
|
|
42
|
+
if section:
|
|
43
|
+
heading = f"## {section}"
|
|
44
|
+
if heading in lines:
|
|
45
|
+
idx = lines.index(heading)
|
|
46
|
+
insert_at = idx + 1
|
|
47
|
+
while insert_at < len(lines) and not lines[insert_at].startswith("## "):
|
|
48
|
+
insert_at += 1
|
|
49
|
+
lines.insert(insert_at, bullet)
|
|
50
|
+
else:
|
|
51
|
+
lines += ["", heading, bullet]
|
|
52
|
+
else:
|
|
53
|
+
lines.append(bullet)
|
|
54
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
56
|
+
return Result(True, f"filed backlog item '{item_id}'", [p])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def prune(graph: Graph, item_id: str) -> Result:
|
|
60
|
+
p = _path(graph)
|
|
61
|
+
if not p.exists():
|
|
62
|
+
return Result(False, "no backlog.md")
|
|
63
|
+
kept, removed = [], False
|
|
64
|
+
for line in p.read_text(encoding="utf-8").splitlines():
|
|
65
|
+
m = _ITEM.match(line)
|
|
66
|
+
if m and m.group("id").strip() == item_id:
|
|
67
|
+
removed = True
|
|
68
|
+
continue
|
|
69
|
+
kept.append(line)
|
|
70
|
+
if not removed:
|
|
71
|
+
return Result(False, f"no backlog item '{item_id}'")
|
|
72
|
+
p.write_text("\n".join(kept) + "\n", encoding="utf-8")
|
|
73
|
+
return Result(True, f"pruned backlog item '{item_id}'", [p])
|
ostler/cli.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""ostler command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import (
|
|
11
|
+
__version__,
|
|
12
|
+
backlog as backlog_mod,
|
|
13
|
+
crud,
|
|
14
|
+
doctor,
|
|
15
|
+
edit,
|
|
16
|
+
freeze as freeze_mod,
|
|
17
|
+
query as query_mod,
|
|
18
|
+
registry,
|
|
19
|
+
select,
|
|
20
|
+
todo as todo_mod,
|
|
21
|
+
trace,
|
|
22
|
+
)
|
|
23
|
+
from .model import load
|
|
24
|
+
|
|
25
|
+
_TYPES = tuple(t.name for t in registry.REGISTRY) + ("seed", "gap")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
p = argparse.ArgumentParser(prog="ostler", description="Tend your documentation graph.")
|
|
30
|
+
p.add_argument("--version", action="version", version=f"ostler {__version__}")
|
|
31
|
+
p.add_argument("-C", "--chdir", metavar="DIR", help="operate as if run from DIR")
|
|
32
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
33
|
+
|
|
34
|
+
d = sub.add_parser("doctor", help="referential-integrity check")
|
|
35
|
+
d.add_argument("--epic", help="restrict checks to one epic (name or folder)")
|
|
36
|
+
d.add_argument("--json", action="store_true", help="emit the structured report as JSON")
|
|
37
|
+
d.add_argument("--no-schema", action="store_true", help="skip JSON Schema validation")
|
|
38
|
+
|
|
39
|
+
t = sub.add_parser("trace", help="walk the graph from a node")
|
|
40
|
+
t.add_argument("token", help="seed id, story slug, gap id, surface or doc path")
|
|
41
|
+
|
|
42
|
+
# ---- retrieval --------------------------------------------------------
|
|
43
|
+
ls = sub.add_parser("list", help="list Concepts of a type")
|
|
44
|
+
ls.add_argument("--type", required=True, choices=_TYPES, dest="etype")
|
|
45
|
+
ls.add_argument("--epic")
|
|
46
|
+
ls.add_argument("--status")
|
|
47
|
+
ls.add_argument("--json", action="store_true")
|
|
48
|
+
|
|
49
|
+
se = sub.add_parser("search", help="full-text search over Concepts")
|
|
50
|
+
se.add_argument("q")
|
|
51
|
+
se.add_argument("--type", choices=_TYPES, dest="etype")
|
|
52
|
+
se.add_argument("--owner")
|
|
53
|
+
se.add_argument("--tag")
|
|
54
|
+
se.add_argument("--json", action="store_true")
|
|
55
|
+
|
|
56
|
+
qy = sub.add_parser("query", help="reverse-index queries")
|
|
57
|
+
qy.add_argument("name", choices=query_mod.QUERIES)
|
|
58
|
+
qy.add_argument("arg")
|
|
59
|
+
qy.add_argument("--json", action="store_true")
|
|
60
|
+
|
|
61
|
+
ne = sub.add_parser("next-epic", help="the next epic with unfinished work")
|
|
62
|
+
ne.add_argument("--json", action="store_true")
|
|
63
|
+
ns = sub.add_parser("next-story", help="the next runnable story in an epic")
|
|
64
|
+
ns.add_argument("epic")
|
|
65
|
+
ns.add_argument("--json", action="store_true")
|
|
66
|
+
|
|
67
|
+
# ---- CRUD -------------------------------------------------------------
|
|
68
|
+
cr = sub.add_parser("create", help="create an epic/story/feature (allocates an id)")
|
|
69
|
+
crs = cr.add_subparsers(dest="what", required=True)
|
|
70
|
+
cre = crs.add_parser("epic")
|
|
71
|
+
cre.add_argument("name")
|
|
72
|
+
cre.add_argument("--title", required=True)
|
|
73
|
+
cre.add_argument("--prefix")
|
|
74
|
+
cre.add_argument("--json", action="store_true")
|
|
75
|
+
crt = crs.add_parser("story")
|
|
76
|
+
crt.add_argument("epic")
|
|
77
|
+
crt.add_argument("slug")
|
|
78
|
+
crt.add_argument("--title", required=True)
|
|
79
|
+
crt.add_argument("--covers", default="")
|
|
80
|
+
crt.add_argument("--depends", default="")
|
|
81
|
+
crt.add_argument("--prefix")
|
|
82
|
+
crt.add_argument("--json", action="store_true")
|
|
83
|
+
crf = crs.add_parser("feature")
|
|
84
|
+
crf.add_argument("slug")
|
|
85
|
+
crf.add_argument("--title", required=True)
|
|
86
|
+
crf.add_argument("--area", default="")
|
|
87
|
+
crf.add_argument("--route", default="")
|
|
88
|
+
crf.add_argument("--prefix")
|
|
89
|
+
crf.add_argument("--json", action="store_true")
|
|
90
|
+
|
|
91
|
+
dl = sub.add_parser("delete", help="delete an epic/story/feature")
|
|
92
|
+
dls = dl.add_subparsers(dest="what", required=True)
|
|
93
|
+
dls.add_parser("epic").add_argument("name")
|
|
94
|
+
dls.add_parser("story").add_argument("slug")
|
|
95
|
+
dls.add_parser("feature").add_argument("slug")
|
|
96
|
+
|
|
97
|
+
sd = sub.add_parser("seed", help="add/remove a seed in an epic")
|
|
98
|
+
sds = sd.add_subparsers(dest="op", required=True)
|
|
99
|
+
sda = sds.add_parser("add")
|
|
100
|
+
sda.add_argument("epic")
|
|
101
|
+
sda.add_argument("id")
|
|
102
|
+
sda.add_argument("--status", default=registry.DEFAULT_SEED_STATUS)
|
|
103
|
+
sda.add_argument("--summary", default="")
|
|
104
|
+
sda.add_argument("--surface", default="")
|
|
105
|
+
sda.add_argument("--legacy-surface", default="", dest="legacy_surface")
|
|
106
|
+
sda.add_argument("--backing", default="")
|
|
107
|
+
sda.add_argument("--prerequisites", default="")
|
|
108
|
+
sda.add_argument("--source-bullet", default="", dest="source_bullet")
|
|
109
|
+
sdr = sds.add_parser("remove")
|
|
110
|
+
sdr.add_argument("epic")
|
|
111
|
+
sdr.add_argument("id")
|
|
112
|
+
|
|
113
|
+
ss = sub.add_parser("set-status", help="set a story's status")
|
|
114
|
+
ss.add_argument("slug")
|
|
115
|
+
ss.add_argument("status")
|
|
116
|
+
|
|
117
|
+
bl = sub.add_parser("backlog", help="manage docs/backlog.md")
|
|
118
|
+
bls = bl.add_subparsers(dest="op", required=True)
|
|
119
|
+
bla = bls.add_parser("add")
|
|
120
|
+
bla.add_argument("id")
|
|
121
|
+
bla.add_argument("text")
|
|
122
|
+
bla.add_argument("--section", default="")
|
|
123
|
+
bls.add_parser("prune").add_argument("id")
|
|
124
|
+
bls.add_parser("list").add_argument("--json", action="store_true")
|
|
125
|
+
|
|
126
|
+
td = sub.add_parser("todo", help="manage the epics queue (docs/epics/index.md)")
|
|
127
|
+
tds = td.add_subparsers(dest="op", required=True)
|
|
128
|
+
tda = tds.add_parser("add")
|
|
129
|
+
tda.add_argument("name")
|
|
130
|
+
tda.add_argument("--front", action="store_true")
|
|
131
|
+
tds.add_parser("prune").add_argument("name")
|
|
132
|
+
tds.add_parser("reorder").add_argument("names", nargs="+")
|
|
133
|
+
tds.add_parser("list").add_argument("--json", action="store_true")
|
|
134
|
+
|
|
135
|
+
# ---- edit / freeze ----------------------------------------------------
|
|
136
|
+
write_parent = argparse.ArgumentParser(add_help=False)
|
|
137
|
+
write_parent.add_argument("--write", action="store_true", default=argparse.SUPPRESS,
|
|
138
|
+
help="apply changes (default: dry-run)")
|
|
139
|
+
e = sub.add_parser("edit", parents=[write_parent],
|
|
140
|
+
help="structured edits (dry-run unless --write)")
|
|
141
|
+
esub = e.add_subparsers(dest="op", required=True)
|
|
142
|
+
so = esub.add_parser("set-owner", parents=[write_parent])
|
|
143
|
+
so.add_argument("gap")
|
|
144
|
+
so.add_argument("story")
|
|
145
|
+
rl = esub.add_parser("relink", parents=[write_parent])
|
|
146
|
+
rl.add_argument("old_path")
|
|
147
|
+
rl.add_argument("new_path")
|
|
148
|
+
rn = esub.add_parser("rename", parents=[write_parent])
|
|
149
|
+
rn.add_argument("old_slug")
|
|
150
|
+
rn.add_argument("new_slug")
|
|
151
|
+
|
|
152
|
+
fz = sub.add_parser("freeze", help="pin an approved story/seed as immutable ground truth")
|
|
153
|
+
fz.add_argument("ident")
|
|
154
|
+
fz.add_argument("--by", default="")
|
|
155
|
+
fz.add_argument("--note", default="")
|
|
156
|
+
uf = sub.add_parser("unfreeze", help="lift the freeze on a story/seed")
|
|
157
|
+
uf.add_argument("ident")
|
|
158
|
+
return p
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
def _emit(rows, as_json: bool) -> int:
|
|
163
|
+
if as_json:
|
|
164
|
+
print(json.dumps(rows, indent=2))
|
|
165
|
+
elif isinstance(rows, list):
|
|
166
|
+
for r in rows:
|
|
167
|
+
print(json.dumps(r) if isinstance(r, dict) else r)
|
|
168
|
+
if not rows:
|
|
169
|
+
print("(none)")
|
|
170
|
+
else:
|
|
171
|
+
print(json.dumps(rows, indent=2) if rows else "(none)")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _result(res, as_json: bool = False) -> int:
|
|
176
|
+
if as_json:
|
|
177
|
+
print(json.dumps({"ok": res.ok, "id": res.entity_id, "message": res.message}))
|
|
178
|
+
else:
|
|
179
|
+
print(res.message)
|
|
180
|
+
return 0 if res.ok else 1
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _cmd_doctor(graph, args) -> int:
|
|
184
|
+
report = doctor.run(graph, epic_filter=args.epic, check_schema=not args.no_schema)
|
|
185
|
+
if args.json:
|
|
186
|
+
print(json.dumps(report.as_dict(), indent=2))
|
|
187
|
+
return 1 if report.errors else 0
|
|
188
|
+
print(f"org: {report.org} profile: {report.profile}")
|
|
189
|
+
for facts in report.epics:
|
|
190
|
+
orphans = facts["orphanActiveSeeds"]
|
|
191
|
+
print(f" epic {facts['dir']}: {facts['storyCount']} stories, "
|
|
192
|
+
f"{facts['activeSeedCount']} active seeds ({facts['coveredActiveSeeds']} covered)"
|
|
193
|
+
+ (f" orphans: {', '.join(orphans)}" if orphans else ""))
|
|
194
|
+
if report.findings:
|
|
195
|
+
print()
|
|
196
|
+
for fnd in sorted(report.findings, key=lambda x: (x.severity != "error", x.code)):
|
|
197
|
+
mark = "✗" if fnd.severity == "error" else "⚠"
|
|
198
|
+
scope = f"[{fnd.epic}] " if fnd.epic else ""
|
|
199
|
+
print(f" {mark} {fnd.code}: {scope}{fnd.message}")
|
|
200
|
+
print(f"\n{report.errors} error(s), {report.warnings} warning(s)")
|
|
201
|
+
return 1 if report.errors else 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _cmd_edit(graph, args) -> int:
|
|
205
|
+
if args.op == "set-owner":
|
|
206
|
+
plan = edit.set_owner(graph, args.gap, args.story)
|
|
207
|
+
elif args.op == "relink":
|
|
208
|
+
plan = edit.relink(graph, args.old_path, args.new_path)
|
|
209
|
+
else:
|
|
210
|
+
plan = edit.rename(graph, args.old_slug, args.new_slug)
|
|
211
|
+
print(plan.render())
|
|
212
|
+
if plan.error:
|
|
213
|
+
return 1
|
|
214
|
+
if getattr(args, "write", False):
|
|
215
|
+
plan.apply()
|
|
216
|
+
print(f"\napplied: {len(plan.changes)} file(s) changed, {len(plan.moves)} move(s)")
|
|
217
|
+
elif plan.changes or plan.moves:
|
|
218
|
+
print("\n(dry-run — pass --write to apply)")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _split(csv: str) -> list[str]:
|
|
223
|
+
return [p.strip() for p in csv.split(",") if p.strip()]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main(argv: list[str] | None = None) -> int: # noqa: C901 — flat command dispatch
|
|
227
|
+
args = _build_parser().parse_args(argv)
|
|
228
|
+
cwd = Path(args.chdir) if args.chdir else None
|
|
229
|
+
graph = load(cwd)
|
|
230
|
+
c = args.command
|
|
231
|
+
|
|
232
|
+
if c == "doctor":
|
|
233
|
+
return _cmd_doctor(graph, args)
|
|
234
|
+
if c == "trace":
|
|
235
|
+
lines, found = trace.run(graph, args.token)
|
|
236
|
+
print("\n".join(lines))
|
|
237
|
+
return 0 if found else 1
|
|
238
|
+
if c == "list":
|
|
239
|
+
return _emit(query_mod.list_entities(graph, args.etype, args.epic, args.status), args.json)
|
|
240
|
+
if c == "search":
|
|
241
|
+
return _emit(query_mod.search(graph, args.q, args.etype, args.owner, args.tag), args.json)
|
|
242
|
+
if c == "query":
|
|
243
|
+
return _emit(query_mod.query(graph, args.name, args.arg), args.json)
|
|
244
|
+
if c == "next-epic":
|
|
245
|
+
return _emit(select.next_epic(graph), args.json)
|
|
246
|
+
if c == "next-story":
|
|
247
|
+
return _emit(select.next_story(graph, args.epic), args.json)
|
|
248
|
+
if c == "create":
|
|
249
|
+
if args.what == "epic":
|
|
250
|
+
res = crud.create_epic(graph, args.name, args.title, args.prefix)
|
|
251
|
+
elif args.what == "story":
|
|
252
|
+
res = crud.create_story(graph, args.epic, args.slug, args.title,
|
|
253
|
+
_split(args.covers), _split(args.depends), args.prefix)
|
|
254
|
+
else:
|
|
255
|
+
res = crud.create_feature(graph, args.slug, args.title, args.area, args.route, args.prefix)
|
|
256
|
+
return _result(res, getattr(args, "json", False))
|
|
257
|
+
if c == "delete":
|
|
258
|
+
if args.what == "epic":
|
|
259
|
+
return _result(crud.delete_epic(graph, args.name))
|
|
260
|
+
if args.what == "story":
|
|
261
|
+
return _result(crud.delete_story(graph, args.slug))
|
|
262
|
+
return _result(crud.delete_feature(graph, args.slug))
|
|
263
|
+
if c == "seed":
|
|
264
|
+
if args.op == "add":
|
|
265
|
+
meta = {"surface": args.surface, "legacySurface": args.legacy_surface,
|
|
266
|
+
"backing": args.backing, "prerequisites": args.prerequisites,
|
|
267
|
+
"sourceBullet": args.source_bullet}
|
|
268
|
+
return _result(crud.add_seed(graph, args.epic, args.id, args.status, args.summary, meta))
|
|
269
|
+
return _result(crud.remove_seed(graph, args.epic, args.id))
|
|
270
|
+
if c == "set-status":
|
|
271
|
+
return _result(crud.set_status(graph, args.slug, args.status))
|
|
272
|
+
if c == "backlog":
|
|
273
|
+
if args.op == "add":
|
|
274
|
+
return _result(backlog_mod.add(graph, args.id, args.text, args.section))
|
|
275
|
+
if args.op == "prune":
|
|
276
|
+
return _result(backlog_mod.prune(graph, args.id))
|
|
277
|
+
return _emit([{"id": i, "text": t} for i, t in backlog_mod.items(graph)], args.json)
|
|
278
|
+
if c == "todo":
|
|
279
|
+
if args.op == "add":
|
|
280
|
+
return _result(todo_mod.add(graph, args.name, front=args.front))
|
|
281
|
+
if args.op == "prune":
|
|
282
|
+
return _result(todo_mod.prune(graph, args.name))
|
|
283
|
+
if args.op == "reorder":
|
|
284
|
+
return _result(todo_mod.reorder(graph, args.names))
|
|
285
|
+
return _emit(todo_mod.list_epics(graph), args.json)
|
|
286
|
+
if c == "edit":
|
|
287
|
+
return _cmd_edit(graph, args)
|
|
288
|
+
if c == "freeze":
|
|
289
|
+
plan = freeze_mod.freeze(graph, args.ident, by=args.by, note=args.note)
|
|
290
|
+
print(plan.render())
|
|
291
|
+
if plan.error:
|
|
292
|
+
return 1
|
|
293
|
+
plan.apply()
|
|
294
|
+
print(f"frozen — recorded in {(graph.root / '.agents' / 'ids.json').as_posix()}")
|
|
295
|
+
return 0
|
|
296
|
+
if c == "unfreeze":
|
|
297
|
+
plan = freeze_mod.unfreeze(graph, args.ident)
|
|
298
|
+
print(plan.render())
|
|
299
|
+
if plan.error:
|
|
300
|
+
return 1
|
|
301
|
+
plan.apply()
|
|
302
|
+
return 0
|
|
303
|
+
return 2
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
sys.exit(main())
|
ostler/crud.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""`ostler` mutation — create/delete epics, stories, features; add/remove seeds; set status.
|
|
2
|
+
|
|
3
|
+
All structural mutation goes through here so id allocation (``ids.py``) and the canonical markdown
|
|
4
|
+
layout (``SPEC.md`` / ``registry.py``) stay correct. Writers apply immediately and return a
|
|
5
|
+
:class:`Result`; the CLI prints its message.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from . import ids, markdown, registry
|
|
17
|
+
from .model import Graph
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Result:
|
|
22
|
+
ok: bool
|
|
23
|
+
message: str
|
|
24
|
+
paths: list[Path] = field(default_factory=list)
|
|
25
|
+
entity_id: str = "" # the allocated id, for create commands (consumed via --json)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# markdown section helpers (operate on a MarkdownDoc's body, preserving frontmatter)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
def _insert_subsection(doc: markdown.MarkdownDoc, heading: str, block: list[str]) -> None:
|
|
32
|
+
"""Insert a ``### …`` *block* under the ``## heading`` section, creating it if absent."""
|
|
33
|
+
body_lines = doc.body.split("\n")
|
|
34
|
+
sec = doc.find_section(heading)
|
|
35
|
+
if sec is None:
|
|
36
|
+
out = list(body_lines)
|
|
37
|
+
while out and out[-1].strip() == "":
|
|
38
|
+
out.pop()
|
|
39
|
+
out += ["", f"## {heading}", "", *block]
|
|
40
|
+
doc.body = "\n".join(out) + "\n"
|
|
41
|
+
else:
|
|
42
|
+
at = sec.line_end
|
|
43
|
+
doc.body = "\n".join(body_lines[:at] + block + body_lines[at:])
|
|
44
|
+
doc._sections = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _remove_subsection(doc: markdown.MarkdownDoc, heading: str, sub_title: str) -> bool:
|
|
48
|
+
sec = doc.find_section(heading)
|
|
49
|
+
if sec is None:
|
|
50
|
+
return False
|
|
51
|
+
for child in sec.children:
|
|
52
|
+
if child.title.strip() == sub_title:
|
|
53
|
+
body_lines = doc.body.split("\n")
|
|
54
|
+
del body_lines[child.line_start:child.line_end]
|
|
55
|
+
doc.body = "\n".join(body_lines)
|
|
56
|
+
doc._sections = None
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _dump_frontmatter(fm: dict) -> str:
|
|
62
|
+
return yaml.safe_dump(fm, sort_keys=False, allow_unicode=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# epics
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
def create_epic(graph: Graph, name: str, title: str, prefix: str | None = None) -> Result:
|
|
69
|
+
edir = graph.doc_roots["epics"] / name
|
|
70
|
+
epic_md = edir / "epic.md"
|
|
71
|
+
if epic_md.exists():
|
|
72
|
+
return Result(False, f"epic '{name}' already exists")
|
|
73
|
+
eid = ids.allocate(graph, prefix)
|
|
74
|
+
fm = {"type": "epic", "id": eid, "title": title, "status": "planned"}
|
|
75
|
+
text = f"---\n{_dump_frontmatter(fm)}---\n# Epic: {title}\n\n## Seeds\n\n## Stories\n"
|
|
76
|
+
edir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
epic_md.write_text(text, encoding="utf-8")
|
|
78
|
+
return Result(True, f"created epic '{name}' ({eid})", [epic_md], entity_id=eid)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def delete_epic(graph: Graph, name: str) -> Result:
|
|
82
|
+
edir = graph.doc_roots["epics"] / name
|
|
83
|
+
if not (edir / "epic.md").exists():
|
|
84
|
+
return Result(False, f"no epic '{name}'")
|
|
85
|
+
import shutil
|
|
86
|
+
shutil.rmtree(edir)
|
|
87
|
+
todo = graph.doc_roots["epics"] / "index.md"
|
|
88
|
+
removed = ""
|
|
89
|
+
if todo.exists():
|
|
90
|
+
from . import todo as todo_mod
|
|
91
|
+
if todo_mod.prune(graph, name).ok:
|
|
92
|
+
removed = " (removed from epics index)"
|
|
93
|
+
return Result(True, f"deleted epic '{name}'{removed}", [edir])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# stories
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
def _story_block(slug: str, title: str, sid: str,
|
|
100
|
+
covers: list[str], depends: list[str]) -> list[str]:
|
|
101
|
+
return [
|
|
102
|
+
f"### {slug}",
|
|
103
|
+
f"- title: {title}",
|
|
104
|
+
f"- id: {sid}",
|
|
105
|
+
f"- covers: {', '.join(covers) if covers else '(none)'}",
|
|
106
|
+
f"- depends on: {', '.join(depends) if depends else '(none)'}",
|
|
107
|
+
"",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def create_story(graph: Graph, epic_name: str, slug: str, title: str,
|
|
112
|
+
covers: list[str] | None = None, depends: list[str] | None = None,
|
|
113
|
+
prefix: str | None = None) -> Result:
|
|
114
|
+
edir = graph.doc_roots["epics"] / epic_name
|
|
115
|
+
epic_md = edir / "epic.md"
|
|
116
|
+
if not epic_md.exists():
|
|
117
|
+
return Result(False, f"no epic '{epic_name}'")
|
|
118
|
+
story_md = edir / "stories" / slug / "story.md"
|
|
119
|
+
if story_md.exists():
|
|
120
|
+
return Result(False, f"story '{slug}' already exists")
|
|
121
|
+
|
|
122
|
+
sid = ids.allocate(graph, prefix)
|
|
123
|
+
doc = markdown.split(epic_md.read_text(encoding="utf-8"))
|
|
124
|
+
_insert_subsection(doc, registry.STORIES_HEADING,
|
|
125
|
+
_story_block(slug, title, sid, covers or [], depends or []))
|
|
126
|
+
epic_md.write_text(doc.render(), encoding="utf-8")
|
|
127
|
+
|
|
128
|
+
fm = {"type": "story", "slug": slug, "status": "Not started"}
|
|
129
|
+
body = (f"# Story: {title}\n\n## Context\n\n## Acceptance Criteria\n\n"
|
|
130
|
+
f"## Implementation Status\n\n- **Status**: Not started\n")
|
|
131
|
+
story_md.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
story_md.write_text(f"---\n{_dump_frontmatter(fm)}---\n{body}", encoding="utf-8")
|
|
133
|
+
return Result(True, f"created story '{slug}' ({sid}) in epic '{epic_name}'",
|
|
134
|
+
[epic_md, story_md], entity_id=sid)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def delete_story(graph: Graph, slug: str) -> Result:
|
|
138
|
+
found = graph.find_story(slug)
|
|
139
|
+
if found is None:
|
|
140
|
+
return Result(False, f"no story '{slug}'")
|
|
141
|
+
epic, story = found
|
|
142
|
+
epic_md = epic.epic_md
|
|
143
|
+
doc = markdown.split(epic_md.read_text(encoding="utf-8"))
|
|
144
|
+
_remove_subsection(doc, registry.STORIES_HEADING, slug)
|
|
145
|
+
epic_md.write_text(doc.render(), encoding="utf-8")
|
|
146
|
+
if story.story_md and story.story_md.exists():
|
|
147
|
+
import shutil
|
|
148
|
+
shutil.rmtree(story.story_md.parent)
|
|
149
|
+
return Result(True, f"deleted story '{slug}' from epic '{epic.name}'", [epic_md])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def set_status(graph: Graph, slug: str, status: str) -> Result:
|
|
153
|
+
found = graph.find_story(slug)
|
|
154
|
+
if found is None or found[1].story_md is None:
|
|
155
|
+
return Result(False, f"no story '{slug}' with a story.md")
|
|
156
|
+
path = found[1].story_md
|
|
157
|
+
doc = markdown.split(path.read_text(encoding="utf-8"))
|
|
158
|
+
fm = doc.frontmatter or {"type": "story", "slug": slug}
|
|
159
|
+
fm["status"] = status
|
|
160
|
+
doc.raw_frontmatter = _dump_frontmatter(fm)
|
|
161
|
+
doc.body = re.sub(r"(\*\*Status\*\*:\s*).*", lambda m: m.group(1) + status,
|
|
162
|
+
doc.body, count=1)
|
|
163
|
+
path.write_text(doc.render(), encoding="utf-8")
|
|
164
|
+
return Result(True, f"status of '{slug}' → {status}", [path])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# seeds (live in epic.md `## Seeds`)
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
def add_seed(graph: Graph, epic_name: str, seed_id: str, status: str = registry.DEFAULT_SEED_STATUS,
|
|
171
|
+
summary: str = "", meta: dict | None = None) -> Result:
|
|
172
|
+
edir = graph.doc_roots["epics"] / epic_name
|
|
173
|
+
epic_md = edir / "epic.md"
|
|
174
|
+
if not epic_md.exists():
|
|
175
|
+
return Result(False, f"no epic '{epic_name}'")
|
|
176
|
+
if status not in registry.SEED_STATUSES:
|
|
177
|
+
return Result(False, f"invalid status '{status}' (one of {', '.join(registry.SEED_STATUSES)})")
|
|
178
|
+
doc = markdown.split(epic_md.read_text(encoding="utf-8"))
|
|
179
|
+
sec = doc.find_section(registry.SEEDS_HEADING)
|
|
180
|
+
if sec is not None and any(c.title.strip() == seed_id for c in sec.children):
|
|
181
|
+
return Result(False, f"seed '{seed_id}' already exists in '{epic_name}'")
|
|
182
|
+
block = [f"### {seed_id}", f"- status: {status}"]
|
|
183
|
+
for key in ("surface", "legacySurface", "backing", "prerequisites", "sourceBullet"):
|
|
184
|
+
val = (meta or {}).get(key)
|
|
185
|
+
if val:
|
|
186
|
+
block.append(f"- {key}: {val}")
|
|
187
|
+
block.append("")
|
|
188
|
+
if summary:
|
|
189
|
+
block += [summary, ""]
|
|
190
|
+
_insert_subsection(doc, registry.SEEDS_HEADING, block)
|
|
191
|
+
epic_md.write_text(doc.render(), encoding="utf-8")
|
|
192
|
+
return Result(True, f"added seed '{seed_id}' to epic '{epic_name}'", [epic_md])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def remove_seed(graph: Graph, epic_name: str, seed_id: str) -> Result:
|
|
196
|
+
edir = graph.doc_roots["epics"] / epic_name
|
|
197
|
+
epic_md = edir / "epic.md"
|
|
198
|
+
if not epic_md.exists():
|
|
199
|
+
return Result(False, f"no epic '{epic_name}'")
|
|
200
|
+
doc = markdown.split(epic_md.read_text(encoding="utf-8"))
|
|
201
|
+
if not _remove_subsection(doc, registry.SEEDS_HEADING, seed_id):
|
|
202
|
+
return Result(False, f"no seed '{seed_id}' in '{epic_name}'")
|
|
203
|
+
epic_md.write_text(doc.render(), encoding="utf-8")
|
|
204
|
+
return Result(True, f"removed seed '{seed_id}' from epic '{epic_name}'", [epic_md])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# features
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
def create_feature(graph: Graph, slug: str, title: str, area: str = "",
|
|
211
|
+
route: str = "", prefix: str | None = None) -> Result:
|
|
212
|
+
froot = graph.doc_roots["features"]
|
|
213
|
+
path = (froot / area / f"{slug}.md") if area else (froot / f"{slug}.md")
|
|
214
|
+
if path.exists():
|
|
215
|
+
return Result(False, f"feature '{slug}' already exists")
|
|
216
|
+
fid = ids.allocate(graph, prefix)
|
|
217
|
+
fm = {"type": "feature", "id": fid, "slug": slug, "title": title}
|
|
218
|
+
if area:
|
|
219
|
+
fm["area"] = area
|
|
220
|
+
if route:
|
|
221
|
+
fm["route"] = route
|
|
222
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
path.write_text(f"---\n{_dump_frontmatter(fm)}---\n# {title}\n\n", encoding="utf-8")
|
|
224
|
+
return Result(True, f"created feature '{slug}' ({fid})", [path], entity_id=fid)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def delete_feature(graph: Graph, slug: str) -> Result:
|
|
228
|
+
for feat in graph.features:
|
|
229
|
+
if feat.slug == slug:
|
|
230
|
+
feat.path.unlink()
|
|
231
|
+
return Result(True, f"deleted feature '{slug}'", [feat.path])
|
|
232
|
+
return Result(False, f"no feature '{slug}'")
|