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 ADDED
@@ -0,0 +1,3 @@
1
+ """ostler — define, validate, search and mutate a repository's markdown knowledge graph (OKF)."""
2
+
3
+ __version__ = "1.0.0"
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}'")