@antoneeo/mkt-agentic-sdlc-skill 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1362 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """The docs site (F-072): what the agent reads, made visible to the user.
4
+
5
+ python <skill_dir>/scripts/docs_site.py [--root <project>] [--docs-dir <name>]
6
+ [--out <folder>] [--open]
7
+
8
+ Turns a project's docs root into a static website opened from disk: what the
9
+ project is, what was done and when, what is open with its next step, and what is
10
+ known -- the same sources the agent reads to orient itself and resume work, no
11
+ more and no less. Read-only on the docs root, stdlib only, no network.
12
+
13
+ Shared spine file: byte-identical in every distribution of the family. It imports
14
+ sdlc_core and knowledge and changes neither. Listing documents, workstreams,
15
+ analyses, topics, claims, locators and the catalog goes through their readers; the
16
+ site adds what they do not offer -- a frontmatter split that keeps continuation
17
+ lines for display, the Vision's status line, and the `derived_from` ladder of
18
+ `kb_time_cycle` (a closure there), restated here with the inside-the-root rule. It is not named `site.py` -- that would shadow
19
+ Python's `site` module.
20
+
21
+ What it writes, outside the docs root: the page files (index.html, app.js,
22
+ style.css, strings.js) copied from `../docs_site/`, and `data/*.js` -- classic
23
+ scripts, because a page opened from disk cannot fetch JSON. All wording lives in
24
+ strings.js; this file emits codes, never interface text.
25
+ """
26
+ import argparse
27
+ import html
28
+ import json
29
+ import math
30
+ import os
31
+ import posixpath
32
+ import re
33
+ import shutil
34
+ import subprocess
35
+ import sys
36
+ import unicodedata
37
+ import urllib.parse
38
+ from datetime import datetime
39
+ from pathlib import Path
40
+
41
+ HERE = Path(__file__).resolve().parent
42
+ sys.path.insert(0, str(HERE))
43
+
44
+ import sdlc_core # noqa: E402
45
+ import knowledge # noqa: E402
46
+
47
+ PAGE_DIR = HERE.parent / "docs_site"
48
+ PAGE_FILES = ("index.html", "app.js", "style.css", "strings.js")
49
+ MARKER = ".docs-site-generated"
50
+ DEFAULT_OUT = ".docs-site"
51
+
52
+ # Status words -> group (Vision v9). A value marking a unit as available or next to
53
+ # take (READY, NEXT) is NOT a group: it falls under "other status", as written.
54
+ STATUS_GROUPS = {"BLOCKED": 1, "PLANNED": 2, "TODO": 2, "IN_PROGRESS": 3, "PROGRESS": 3,
55
+ "PAUSED": 4, "ON_HOLD": 4, "COMPLETED": 5, "DONE": 5, "CANCELLED": 6,
56
+ "ARCHIVED": 7}
57
+ OTHER_STATUS = 8
58
+
59
+ HEX12 = re.compile(r"^[0-9a-f]{12}$")
60
+ DASHES = re.compile("[‐-―−﹘﹣-]")
61
+ INVISIBLE = re.compile("[­​-‏⁠-⁤]")
62
+
63
+
64
+ class SiteError(Exception):
65
+ """A refusal: printed as one line, exit 2."""
66
+
67
+
68
+ # ------------------------------------------------------------------ reading
69
+
70
+ _UNREADABLE = []
71
+
72
+
73
+ def _text(path):
74
+ """The file's text, or None when it cannot be read (recorded, reported, exit 1)."""
75
+ try:
76
+ text = sdlc_core.read_text(path)
77
+ except OSError as exc:
78
+ _note_unreadable(path, exc)
79
+ return None
80
+ if any(Path(p) == Path(path) for p, _e in _UNREADABLE):
81
+ return None # the guard already read it as empty
82
+ return text
83
+
84
+
85
+ def _note_unreadable(path, exc):
86
+ if all(Path(p) != Path(path) for p, _e in _UNREADABLE):
87
+ _UNREADABLE.append((Path(path), exc))
88
+
89
+
90
+ class _GuardedReads:
91
+ """For one run, every read the shared readers make goes through one guard: an
92
+ unreadable file is recorded and read as empty, so the run reports it by name
93
+ and still writes the site. The shared modules' code is untouched; only the
94
+ function their callers look up is wrapped, and restored on exit."""
95
+
96
+ def __enter__(self):
97
+ self.original = sdlc_core.read_text
98
+ original = self.original
99
+
100
+ def guarded(path):
101
+ try:
102
+ return original(path)
103
+ except OSError as exc:
104
+ _note_unreadable(path, exc)
105
+ return ""
106
+ sdlc_core.read_text = guarded
107
+ return self
108
+
109
+ def __exit__(self, *exc):
110
+ sdlc_core.read_text = self.original
111
+ return False
112
+
113
+
114
+ def split_frontmatter(text):
115
+ """(meta pairs as written, body). Pairs keep continuation lines, joined."""
116
+ lines = text.split("\n")
117
+ if not lines or lines[0].strip() != "---":
118
+ return [], text
119
+ end = next((i for i in range(1, min(len(lines), 200)) if lines[i].strip() == "---"), None)
120
+ if end is None:
121
+ return [], text
122
+ pairs = []
123
+ for ln in lines[1:end]:
124
+ m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", ln)
125
+ if m:
126
+ pairs.append([m.group(1), m.group(2).strip()])
127
+ elif pairs and ln.strip():
128
+ item = ln.strip()
129
+ item = item[2:].strip() if item.startswith("- ") else item
130
+ pairs[-1][1] = (pairs[-1][1] + ", " + item) if pairs[-1][1] else item
131
+ return pairs, "\n".join(lines[end + 1:])
132
+
133
+
134
+ def first_heading(body):
135
+ for ln in body.split("\n"):
136
+ if ln.startswith("# "):
137
+ return ln[2:].strip()
138
+ return None
139
+
140
+
141
+ def normalize(s):
142
+ """Search folding: case, accents, dash variants, invisible characters, spaces.
143
+ app.js carries the same function; the battery runs both on the same inputs."""
144
+ s = unicodedata.normalize("NFKD", s or "")
145
+ s = "".join(c for c in s if unicodedata.category(c) != "Mn")
146
+ s = s.lower()
147
+ s = DASHES.sub("-", s)
148
+ s = INVISIBLE.sub("", s)
149
+ return re.sub(r"\s+", " ", s).strip()
150
+
151
+
152
+ def status_group(value):
153
+ return STATUS_GROUPS.get(str(value or "").strip().upper(), OTHER_STATUS)
154
+
155
+
156
+ def _v(meta, key):
157
+ return str(meta.get(key) or "").strip()
158
+
159
+
160
+ # ---------------------------------------------------------------- markdown
161
+ # A formatter with its sanitizer built in: every byte of content is escaped, and
162
+ # the only markup in the output is the markup emitted here -- headings,
163
+ # paragraphs, lists, tables, emphasis, code, blockquotes, rules and links. A link
164
+ # survives only when relative, `#` or http(s); images become their alt text.
165
+
166
+ _SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
167
+ _LINK = re.compile(r"(!?)\[([^\]]*)\]\(\s*<?([^)\s>]*)>?(?:\s+\"[^\"]*\")?\s*\)")
168
+ _CODE = re.compile(r"(`+)(.+?)\1", re.S)
169
+ _AUTO = re.compile(r"&lt;(https?://[^\s&]+)&gt;")
170
+
171
+
172
+ class _Ctx:
173
+ def __init__(self, base="", facts=None, docs=None):
174
+ self.base = base # folder of the document, docs-root relative
175
+ self.facts = facts # set of fact ids, or None without a knowledge base
176
+
177
+
178
+ def _esc(s):
179
+ return html.escape(s, quote=True)
180
+
181
+
182
+ def _href(target, ctx):
183
+ """(href, external) for a link target, or None when it must not be a link."""
184
+ if target.startswith("#"):
185
+ return _esc(target), False
186
+ if re.match(r"^https?://", target, re.I):
187
+ return _esc(target), True
188
+ if _SCHEME.match(target) or target.startswith(("/", "\\")):
189
+ return None
190
+ path, _, _frag = target.partition("#")
191
+ if not path:
192
+ return None
193
+ rel = posixpath.normpath(posixpath.join(ctx.base, urllib.parse.unquote(path)))
194
+ if rel.startswith("..") or rel.startswith("/"):
195
+ return None
196
+ return "#/doc/" + urllib.parse.quote(rel, safe="/._-~"), False
197
+
198
+
199
+ def _inline(text, ctx, links=True):
200
+ out, pos = [], 0
201
+ for m in _CODE.finditer(text):
202
+ out.append(_inline_plain(text[pos:m.start()], ctx, links))
203
+ code = m.group(2).strip() if m.group(2).strip() else m.group(2)
204
+ if ctx.facts is not None and HEX12.match(code):
205
+ if code in ctx.facts:
206
+ out.append('<a class="factid" href="#/fact/%s"><code>%s</code></a>' % (code, code))
207
+ else:
208
+ out.append('<code class="notfact">%s</code>' % code)
209
+ else:
210
+ out.append("<code>%s</code>" % _esc(code))
211
+ pos = m.end()
212
+ out.append(_inline_plain(text[pos:], ctx, links))
213
+ return "".join(out)
214
+
215
+
216
+ def _emphasis(s):
217
+ s = re.sub(r"\*\*(?=\S)(.+?)(?<=\S)\*\*", r"<strong>\1</strong>", s)
218
+ s = re.sub(r"(?<![\w])__(?=\S)(.+?)(?<=\S)__(?![\w])", r"<strong>\1</strong>", s)
219
+ s = re.sub(r"(?<![\*\w])\*(?=[^\s*])(.+?)(?<=[^\s*])\*(?![\*\w])", r"<em>\1</em>", s)
220
+ s = re.sub(r"(?<![\w])_(?=\S)(.+?)(?<=\S)_(?![\w])", r"<em>\1</em>", s)
221
+ return s
222
+
223
+
224
+ def _inline_plain(text, ctx, links):
225
+ if not text:
226
+ return ""
227
+ text = text.replace("\x00", "") # content can never address a held piece of markup
228
+ held = []
229
+
230
+ def hold(markup):
231
+ held.append(markup)
232
+ return "\x00%d\x00" % (len(held) - 1)
233
+
234
+ def link(m):
235
+ image, label, target = m.group(1), m.group(2), m.group(3)
236
+ if image:
237
+ return hold(_esc(label))
238
+ inner = _emphasis(_esc(label))
239
+ h = _href(target, ctx) if links else None
240
+ if h is None:
241
+ return hold(inner)
242
+ href, external = h
243
+ extra = ' target="_blank" rel="noopener noreferrer"' if external else ""
244
+ return hold('<a href="%s"%s>%s</a>' % (href, extra, inner))
245
+
246
+ text = _LINK.sub(link, text)
247
+ s = _emphasis(_esc(text))
248
+ s = _AUTO.sub(lambda m: '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>'
249
+ % (m.group(1), m.group(1)), s)
250
+ return re.sub("\x00(\\d+)\x00", lambda m: held[int(m.group(1))], s)
251
+
252
+
253
+ def _slugify(text):
254
+ s = re.sub(r"[^\w\- ]", "", text.lower()).strip()
255
+ return re.sub(r"\s+", "-", s)
256
+
257
+
258
+ _LIST = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$")
259
+ _HR = re.compile(r"^\s{0,3}([-*_])(\s*\1){2,}\s*$")
260
+ _HEAD = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
261
+
262
+
263
+ def _cells(line):
264
+ s = line.strip()
265
+ if s.startswith("|"):
266
+ s = s[1:]
267
+ if s.endswith("|") and not s.endswith("\\|"):
268
+ s = s[:-1]
269
+ return [c.strip().replace("\\|", "|") for c in re.split(r"(?<!\\)\|", s)]
270
+
271
+
272
+ def _is_sep(line):
273
+ s = line.strip()
274
+ return s.startswith("|") and set(s) <= set("|-: ") and "-" in s
275
+
276
+
277
+ def _starts_block(lines, i):
278
+ ln = lines[i]
279
+ return (ln.strip().startswith("```") or _HEAD.match(ln) or _HR.match(ln)
280
+ or ln.lstrip().startswith(">") or _LIST.match(ln)
281
+ or (ln.strip().startswith("|") and i + 1 < len(lines) and _is_sep(lines[i + 1])))
282
+
283
+
284
+ def _blocks(lines, ctx):
285
+ out, i = [], 0
286
+ while i < len(lines):
287
+ ln = lines[i]
288
+ if not ln.strip():
289
+ i += 1
290
+ continue
291
+ if ln.strip().startswith("```"):
292
+ fence = ln.strip()[:3]
293
+ body, i = [], i + 1
294
+ while i < len(lines) and not lines[i].strip().startswith(fence):
295
+ body.append(lines[i])
296
+ i += 1
297
+ i += 1
298
+ out.append("<pre><code>%s</code></pre>" % _esc("\n".join(body)))
299
+ continue
300
+ m = _HEAD.match(ln)
301
+ if m:
302
+ n = len(m.group(1))
303
+ title = m.group(2)
304
+ out.append('<h%d id="%s">%s</h%d>' % (n, _esc(_slugify(title)), _inline(title, ctx), n))
305
+ i += 1
306
+ continue
307
+ if _HR.match(ln):
308
+ out.append("<hr>")
309
+ i += 1
310
+ continue
311
+ if ln.lstrip().startswith(">"):
312
+ quoted = []
313
+ while i < len(lines) and lines[i].lstrip().startswith(">"):
314
+ q = lines[i].lstrip()[1:]
315
+ quoted.append(q[1:] if q.startswith(" ") else q)
316
+ i += 1
317
+ out.append("<blockquote>%s</blockquote>" % _blocks(quoted, ctx))
318
+ continue
319
+ if ln.strip().startswith("|") and i + 1 < len(lines) and _is_sep(lines[i + 1]):
320
+ head = _cells(ln)
321
+ i += 2
322
+ rows = []
323
+ while i < len(lines) and lines[i].strip().startswith("|"):
324
+ rows.append(_cells(lines[i]))
325
+ i += 1
326
+ th = "".join("<th>%s</th>" % _inline(c, ctx) for c in head)
327
+ body = "".join("<tr>%s</tr>" % "".join("<td>%s</td>" % _inline(c, ctx) for c in r)
328
+ for r in rows)
329
+ out.append("<table><thead><tr>%s</tr></thead><tbody>%s</tbody></table>" % (th, body))
330
+ continue
331
+ if _LIST.match(ln):
332
+ i = _list(lines, i, ctx, out)
333
+ continue
334
+ para = [ln.strip()]
335
+ i += 1
336
+ while i < len(lines) and lines[i].strip() and not _starts_block(lines, i):
337
+ para.append(lines[i].strip())
338
+ i += 1
339
+ out.append("<p>%s</p>" % _inline(" ".join(para), ctx))
340
+ return "".join(out)
341
+
342
+
343
+ def _list(lines, i, ctx, out):
344
+ first = _LIST.match(lines[i])
345
+ indent = len(first.group(1))
346
+ ordered = first.group(2)[0].isdigit()
347
+ items = []
348
+ while i < len(lines):
349
+ m = _LIST.match(lines[i])
350
+ if m and len(m.group(1)) == indent:
351
+ items.append([m.group(3)])
352
+ i += 1
353
+ continue
354
+ if not lines[i].strip():
355
+ nxt = i + 1
356
+ if nxt < len(lines) and lines[nxt].strip() and (
357
+ len(lines[nxt]) - len(lines[nxt].lstrip()) > indent):
358
+ items[-1].append("")
359
+ i += 1
360
+ continue
361
+ break
362
+ lead = len(lines[i]) - len(lines[i].lstrip())
363
+ if lead > indent or (not m and not _starts_block(lines, i)):
364
+ items[-1].append(lines[i][min(lead, indent + 2):] if lead > indent else lines[i].strip())
365
+ i += 1
366
+ continue
367
+ break
368
+ tag = "ol" if ordered else "ul"
369
+ parts = []
370
+ for item in items:
371
+ head, rest = item[0], item[1:]
372
+ if any(_LIST.match(r) or not r.strip() for r in rest) or any(
373
+ r.strip().startswith(("```", ">", "|")) for r in rest):
374
+ cont = [head] + rest
375
+ k = 1
376
+ while k < len(cont) and cont[k].strip() and not _LIST.match(cont[k]):
377
+ k += 1
378
+ inner = "<span>%s</span>" % _inline(" ".join(c.strip() for c in cont[:k]), ctx)
379
+ inner += _blocks(cont[k:], ctx)
380
+ else:
381
+ inner = _inline(" ".join([head] + [r.strip() for r in rest]), ctx)
382
+ parts.append("<li>%s</li>" % inner)
383
+ out.append("<%s>%s</%s>" % (tag, "".join(parts), tag))
384
+ return i
385
+
386
+
387
+ def markdown(text, base="", facts=None):
388
+ """Markdown -> sanitized HTML. `base`: the document's folder, for relative links;
389
+ `facts`: the fact ids, when a knowledge base is present (12-hex tokens link)."""
390
+ return _blocks((text or "").replace("\r\n", "\n").split("\n"), _Ctx(base, facts))
391
+
392
+
393
+ # ------------------------------------------------------------ project facts
394
+
395
+ def resolve_root(args):
396
+ """(project, docs, name) as every entry point resolves them; SiteError otherwise."""
397
+ try:
398
+ discovered, name = sdlc_core.resolve_docs_dir(args, args.root)
399
+ except sdlc_core.AmbiguousDocsRoot as exc:
400
+ raise SiteError(str(exc))
401
+ sdlc_core.set_docs_dir(name)
402
+ project = Path(args.root).resolve() if args.root else (
403
+ discovered or sdlc_core.find_project_root())
404
+ docs = project / name
405
+ if not docs.is_dir():
406
+ raise SiteError("%s not found: refusing to generate a site here. Run the skill's "
407
+ "init first, or pass --root <project_root>." % docs)
408
+ return project, docs, name
409
+
410
+
411
+ def project_lens(project, name):
412
+ if name == "mkt_docs":
413
+ return "marketing"
414
+ return sdlc_core.project_default_domain(project)
415
+
416
+
417
+ def units_page(lens):
418
+ return {"marketing": None, "course": "courses"}.get(lens, "features")
419
+
420
+
421
+ def vision_status(pairs, body):
422
+ for k, v in pairs:
423
+ if k.lower() == "status":
424
+ return v
425
+ for ln in body.split("\n")[:20]:
426
+ m = re.match(r"^\**\s*status\s*:?\s*\**\s*:?\s*(.+?)\s*$", ln, re.I)
427
+ if m:
428
+ return m.group(1).strip()
429
+ return None
430
+
431
+
432
+ def first_section(body, named=None):
433
+ """(title, body) of the first `## ` section -- or of the first one whose title
434
+ starts with `named`, when given (None when there is no such section)."""
435
+ lines = body.split("\n")
436
+ for i, ln in enumerate(lines):
437
+ if ln.startswith("## ") and (named is None or ln[3:].strip().lower().startswith(named)):
438
+ rest = []
439
+ for nxt in lines[i + 1:]:
440
+ if nxt.startswith("## "):
441
+ break
442
+ rest.append(nxt)
443
+ return ln[3:].strip(), "\n".join(rest).strip()
444
+ return None, None
445
+
446
+
447
+ NORTH_STAR = "north star"
448
+
449
+
450
+ def vision_lead(body):
451
+ """(title, body, is_north_star): the Vision's North Star wherever it sits (R25),
452
+ else its first section, which is then not called a North Star."""
453
+ title, text = first_section(body, NORTH_STAR)
454
+ if title:
455
+ return title, text, True
456
+ title, text = first_section(body)
457
+ return title, text, False
458
+
459
+
460
+ def opening_paragraph(body):
461
+ lines = body.split("\n")
462
+ start = next((i + 1 for i, ln in enumerate(lines) if ln.startswith("# ")), 0)
463
+ para = []
464
+ for ln in lines[start:]:
465
+ if not ln.strip():
466
+ if para:
467
+ break
468
+ continue
469
+ if ln.startswith("#"):
470
+ if para:
471
+ break
472
+ continue
473
+ para.append(ln)
474
+ return "\n".join(para)
475
+
476
+
477
+ # ------------------------------------------------------------ knowledge base
478
+
479
+ def resolve_derived(docs, entry, inside_only=True):
480
+ """A `derived_from` entry -> docs-root-relative path, or None.
481
+
482
+ The resolution ladder of `knowledge.kb_time_cycle` (its `_resolve` closure):
483
+ normalize, then a path with `/` must exist as written, a bare name is looked
484
+ up in notes/ then given/. The site adds one rule: the result is followed only
485
+ if it ends inside the docs root."""
486
+ entry = posixpath.normpath(str(entry).strip())
487
+ found = None
488
+ if "/" in entry:
489
+ found = entry if (docs / entry).is_file() else None
490
+ else:
491
+ for base in ("corpus/notes/", "corpus/given/"):
492
+ if (docs / (base + entry)).is_file():
493
+ found = base + entry
494
+ break
495
+ if found is None or not inside_only:
496
+ return found
497
+ try:
498
+ return (docs / found).resolve().relative_to(docs.resolve()).as_posix()
499
+ except (ValueError, OSError):
500
+ return None
501
+
502
+
503
+ def _inside(docs, rel):
504
+ return sdlc_core.confine_under(docs, rel) is not None
505
+
506
+
507
+ class Passages:
508
+ """Reads the span a locator addresses exactly as `kb_check_locator` reads it,
509
+ one text per file (cached). Returns (where, before, passage, after) or a reason."""
510
+
511
+ CONTEXT = 160
512
+
513
+ def __init__(self, docs):
514
+ self.docs = docs
515
+ self.texts = {}
516
+ self.check_cache = {}
517
+
518
+ def _t(self, path):
519
+ if path not in self.texts:
520
+ self.texts[path] = _text(path)
521
+ return self.texts[path]
522
+
523
+ def read(self, rel, loc):
524
+ target = self.docs / rel
525
+ m = knowledge.LOC_PAGE_RE.match(loc)
526
+ if m:
527
+ page, a, b = map(int, m.groups())
528
+ ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
529
+ if not ext.is_file():
530
+ return {"reason": "no_file" if not target.exists() else "no_extraction"}
531
+ errs = []
532
+ knowledge.kb_check_locator(target, loc, "site", errs, self.check_cache)
533
+ if errs:
534
+ return {"reason": "no_span", "where": {"page": page}}
535
+ text = self._t(ext)
536
+ if text is None:
537
+ return {"reason": "unreadable"}
538
+ body = text.split("\f")[page - 1]
539
+ return {"where": {"page": page}, "before": body[max(0, a - self.CONTEXT):a],
540
+ "passage": body[a:b], "after": body[b:b + self.CONTEXT]}
541
+ m = knowledge.LOC_LINE_RE.match(loc)
542
+ if m:
543
+ a, b = map(int, m.groups())
544
+ if not target.is_file():
545
+ return {"reason": "no_file"}
546
+ errs = []
547
+ knowledge.kb_check_locator(target, loc, "site", errs, self.check_cache)
548
+ if errs:
549
+ return {"reason": "no_span", "where": {"lines": [a, b]}}
550
+ text = self._t(target)
551
+ if text is None:
552
+ return {"reason": "unreadable"}
553
+ lines = text.split("\n")
554
+ return {"where": {"lines": [a, b]}, "before": "\n".join(lines[max(0, a - 3):a - 1]),
555
+ "passage": "\n".join(lines[a - 1:b]), "after": "\n".join(lines[b:b + 2])}
556
+ if knowledge.LOC_CELL_RE.match(loc):
557
+ return {"reason": "cell", "where": {"cell": loc}}
558
+ return {"reason": "unknown_form"}
559
+
560
+
561
+ def _kind(row):
562
+ parts = (row.get("state") or "").split(None, 1)
563
+ return (parts[0] if parts else "OK").upper(), (parts[1] if len(parts) > 1 else "")
564
+
565
+
566
+ def _ids(rest):
567
+ return [t.strip() for t in rest.split(",") if t.strip()]
568
+
569
+
570
+ def chain_ends(start, by_id):
571
+ """Every branch of a replacement chain, walked to its end: the current fact,
572
+ or a stated stop -- a loop or a missing id."""
573
+ ends = []
574
+
575
+ def walk(fid, seen):
576
+ if fid not in by_id:
577
+ end = {"stop": "missing", "id": fid}
578
+ elif fid in seen:
579
+ end = {"stop": "loop", "id": fid}
580
+ else:
581
+ kind, rest = _kind(by_id[fid])
582
+ if kind == "SUPERSEDED" and _ids(rest):
583
+ for nxt in _ids(rest):
584
+ walk(nxt, seen | {fid})
585
+ return
586
+ end = {"id": fid}
587
+ if end not in ends:
588
+ ends.append(end)
589
+
590
+ kind, rest = _kind(by_id[start])
591
+ for nxt in _ids(rest):
592
+ walk(nxt, frozenset({start}))
593
+ return ends
594
+
595
+
596
+ class Grounds:
597
+ """What an inferred fact rests on: the anti-echo walk of the probe, following a
598
+ path only when it stays inside the docs root."""
599
+
600
+ def __init__(self, docs):
601
+ self.docs = docs
602
+ self.meta = {}
603
+
604
+ def _meta(self, rel):
605
+ if rel not in self.meta:
606
+ t = _text(self.docs / rel)
607
+ self.meta[rel] = sdlc_core.load_frontmatter(t.splitlines()) if t else {}
608
+ return self.meta[rel]
609
+
610
+ def leaves(self, rel, seen=frozenset()):
611
+ if rel is None:
612
+ return [{"kind": "untraceable"}]
613
+ if rel in seen:
614
+ return [{"kind": "untraceable", "path": rel}]
615
+ seen = seen | {rel}
616
+ if rel.startswith("corpus/given/"):
617
+ return [{"kind": "document", "path": rel}]
618
+ if not _inside(self.docs, rel) or not (self.docs / rel).is_file():
619
+ return [{"kind": "untraceable", "path": rel}]
620
+ meta = self._meta(rel)
621
+ origin = _v(meta, "origin").lower()
622
+ kind = {"elicited": "statement", "ruling": "decision", "imported": "kb"}.get(origin)
623
+ if kind:
624
+ return [{"kind": kind, "path": rel}]
625
+ entries = knowledge._kb_df_entries(meta.get("derived_from"))
626
+ if not entries:
627
+ return [{"kind": "decision" if meta.get("basis") else "untraceable", "path": rel}]
628
+ out = []
629
+ for e in entries:
630
+ target = resolve_derived(self.docs, e)
631
+ got = self.leaves(target, seen) if target else [{"kind": "untraceable", "path": rel,
632
+ "entry": str(e)}]
633
+ for g in got:
634
+ if g not in out:
635
+ out.append(g)
636
+ return out
637
+
638
+
639
+ def ground_class(leaves):
640
+ kinds = {g["kind"] for g in leaves}
641
+ if "statement" in kinds:
642
+ return "statement"
643
+ if "untraceable" in kinds:
644
+ return "untraceable"
645
+ if "kb" in kinds:
646
+ return "imported"
647
+ return "documents"
648
+
649
+
650
+ def topic_area(slug, live, all_nodes):
651
+ """The root reached by first parents; a cycle, a missing or a merged parent -> None."""
652
+ seen, cur = set(), slug
653
+ while True:
654
+ if cur in seen or cur not in live:
655
+ return None
656
+ seen.add(cur)
657
+ parents = knowledge._as_list(live[cur]["meta"].get("parents"))
658
+ if not parents:
659
+ return cur
660
+ cur = parents[0]
661
+
662
+
663
+ def build_kb(docs):
664
+ all_nodes = knowledge.kb_load_topics(docs)
665
+ if not all_nodes:
666
+ return None
667
+ tomb = {s for s, n in all_nodes.items() if _v(n["meta"], "status") == "SUPERSEDED"}
668
+ live = {s: n for s, n in all_nodes.items() if s not in tomb}
669
+ topics, facts, rows_of = {}, {}, {}
670
+ by_id = {}
671
+ for slug in sorted(all_nodes):
672
+ node = all_nodes[slug]
673
+ t = _text(docs / node["file"]) or ""
674
+ _pairs, body = split_frontmatter(t)
675
+ rows, _errs = knowledge.kb_parse_claims(t)
676
+ for r in rows:
677
+ r["_topic"] = slug
678
+ r["id"] = r["id"] or "%s:%d" % (slug, r["_line"])
679
+ by_id.setdefault(r["id"], r)
680
+ rows_of[slug] = rows
681
+ meta = node["meta"]
682
+ if slug in tomb:
683
+ topics[slug] = {"name": slug, "merged_into": _v(meta, "redirect_to")}
684
+ continue
685
+ topics[slug] = {
686
+ "name": first_heading(body) or slug,
687
+ "description": _v(meta, "description"),
688
+ "aka": knowledge._as_list(meta.get("synonyms")),
689
+ "gaps": knowledge._as_list(meta.get("gaps")),
690
+ "related": knowledge._as_list(meta.get("related")),
691
+ "parents": knowledge._as_list(meta.get("parents")),
692
+ "file": node["file"],
693
+ "facts": [r["id"] for r in rows],
694
+ "count": len(rows),
695
+ }
696
+ for slug, t in topics.items():
697
+ if "merged_into" in t:
698
+ continue
699
+ t["area"] = topic_area(slug, live, all_nodes)
700
+ t["children"] = sorted(s for s, o in topics.items()
701
+ if "merged_into" not in o and slug in o["parents"])
702
+ passages, grounds = Passages(docs), Grounds(docs)
703
+ sources_of_topic = {}
704
+ for slug in sorted(rows_of):
705
+ for r in rows_of[slug]:
706
+ kind, rest = _kind(r)
707
+ f = {"text": r["claim"], "valid": r["valid"], "qty": r["qty"], "about": r["about"],
708
+ "origin": (r["prov"] or "").strip().lower(), "topic": slug,
709
+ "state": {"SUPERSEDED": "superseded", "CONTESTED": "contested"}.get(kind, "ok")}
710
+ if kind == "SUPERSEDED":
711
+ f["successors"] = _ids(rest)
712
+ f["chain_ends"] = chain_ends(r["id"], by_id)
713
+ if kind == "CONTESTED":
714
+ f["with"] = _ids(rest)
715
+ srcs = []
716
+ for src in r["source"].split(";"):
717
+ src = src.strip()
718
+ if not src:
719
+ continue
720
+ rel, _, loc = src.rpartition("#") if "#" in src else (src, "", "")
721
+ rel = rel.strip()
722
+ entry = {"path": rel}
723
+ if not _inside(docs, rel):
724
+ entry["reason"] = "outside"
725
+ elif rel.startswith("corpus/given/"):
726
+ entry["source"] = rel[len("corpus/given/"):]
727
+ sources_of_topic.setdefault(entry["source"], set()).add(slug)
728
+ entry.update(passages.read(rel, loc.strip()) if loc else {"reason": "no_locator"})
729
+ elif loc:
730
+ entry.update(passages.read(rel, loc.strip()))
731
+ if loc:
732
+ entry["loc"] = loc.strip()
733
+ srcs.append(entry)
734
+ f["sources"] = srcs
735
+ if f["origin"] == "derived":
736
+ leaves = []
737
+ for rel in knowledge.kb_claim_sources(r):
738
+ rel = rel.strip()
739
+ for g in grounds.leaves(rel if _inside(docs, rel) else None):
740
+ if g not in leaves:
741
+ leaves.append(g)
742
+ f["grounds"] = leaves
743
+ f["ground_class"] = ground_class(leaves)
744
+ f["statement"] = f["origin"] == "elicited" or f.get("ground_class") == "statement"
745
+ facts.setdefault(r["id"], f)
746
+ sources = build_sources(docs, rows_of)
747
+ order = sorted(s for s in topics if "merged_into" not in topics[s])
748
+ return {"topics": topics, "facts": facts, "sources": sources, "order": order,
749
+ "map": build_map(topics, order, facts, sources)}
750
+
751
+
752
+ def build_sources(docs, rows_of):
753
+ given = docs / "corpus" / "given"
754
+ out = {}
755
+ if not given.is_dir():
756
+ return out
757
+ cited = {}
758
+ for slug, rows in rows_of.items():
759
+ for r in rows:
760
+ for rel in sorted(set(knowledge.kb_claim_sources(r))):
761
+ if rel.startswith("corpus/given/"):
762
+ name = rel[len("corpus/given/"):]
763
+ c = cited.setdefault(name, [0, set()])
764
+ c[0] += 1
765
+ c[1].add(slug)
766
+ for mp in sorted(given.glob("*.meta.md")):
767
+ name = mp.name[:-len(".meta.md")]
768
+ t = _text(mp) or ""
769
+ meta = sdlc_core.load_frontmatter(t.splitlines()) or {}
770
+ through = _v(meta, "extracted_through")
771
+ parsed = knowledge.kb_parse_extracted_through(through) if through else None
772
+ if not through:
773
+ read = {"kind": "unrecorded"}
774
+ elif parsed is None:
775
+ read = {"kind": "unreadable", "raw": through}
776
+ elif parsed[0] == "complete":
777
+ read = {"kind": "complete"}
778
+ else:
779
+ total = knowledge.kb_extraction_extent(given / name, parsed[0])
780
+ read = {"kind": "page" if parsed[0] == "p" else "line", "n": parsed[1]}
781
+ if total:
782
+ read["of"] = total
783
+ c = cited.get(name, [0, set()])
784
+ entry = {"kind": Path(name).suffix.lstrip(".").lower(), "date": _v(meta, "date"),
785
+ "read": read, "facts": c[0], "topics": sorted(c[1]),
786
+ "sidecar": "corpus/given/" + mp.name}
787
+ sup = knowledge.kb_unquote(meta.get("supersedes"))
788
+ if sup:
789
+ entry["newer_of"] = sup
790
+ out[name] = entry
791
+ for name, e in list(out.items()):
792
+ older = e.get("newer_of")
793
+ if older in out:
794
+ out[older]["older_of"] = name
795
+ return out
796
+
797
+
798
+ def build_map(topics, order, facts, sources):
799
+ """The three views' geometry, computed here: deterministic, laid out, framed.
800
+
801
+ Topics: a radial tree by area (first parents), unplaced topics on an outer
802
+ ring. Everything: each topic's facts on a small spiral around it, each source
803
+ document at the centre of the topics that use it."""
804
+ pos = {}
805
+ areas = [s for s in order if topics[s].get("area") == s]
806
+ kids = {s: [] for s in order}
807
+ for s in order:
808
+ area = topics[s].get("area")
809
+ if area and area != s:
810
+ parent = topics[s]["parents"][0]
811
+ if parent in kids:
812
+ kids[parent].append(s)
813
+ weight = {}
814
+
815
+ def w(s):
816
+ if s not in weight:
817
+ weight[s] = max(1, sum(w(k) for k in kids[s]))
818
+ return weight[s]
819
+
820
+ total = sum(w(a) for a in areas) or 1
821
+ ring = 260.0
822
+
823
+ def place(s, depth, a0, a1):
824
+ ang = (a0 + a1) / 2
825
+ r = depth * ring
826
+ pos[s] = (round(r * math.cos(ang), 1), round(r * math.sin(ang), 1))
827
+ cur = a0
828
+ for k in kids[s]:
829
+ span = (a1 - a0) * w(k) / w(s)
830
+ place(k, depth + 1, cur, cur + span)
831
+ cur += span
832
+
833
+ cur = 0.0
834
+ for a in areas:
835
+ span = 2 * math.pi * w(a) / total
836
+ place(a, 1 if len(areas) > 1 else 0, cur, cur + span)
837
+ cur += span
838
+ rest = [s for s in order if s not in pos]
839
+ if rest:
840
+ depth = max([0] + [math.hypot(*p) for p in pos.values()]) + ring
841
+ for i, s in enumerate(rest):
842
+ ang = 2 * math.pi * i / len(rest)
843
+ pos[s] = (round(depth * math.cos(ang), 1), round(depth * math.sin(ang), 1))
844
+ nodes = [{"id": s, "x": pos[s][0], "y": pos[s][1], "area": topics[s].get("area"),
845
+ "count": topics[s]["count"]} for s in order]
846
+ edges = []
847
+ for s in order:
848
+ for p in topics[s]["parents"]:
849
+ if p in pos:
850
+ edges.append({"a": s, "b": p, "kind": "part_of"})
851
+ for r in topics[s]["related"]:
852
+ if r in pos:
853
+ edges.append({"a": s, "b": r, "kind": "related"})
854
+ shared = {}
855
+ for name in sorted(sources):
856
+ ts = [t for t in sources[name]["topics"] if t in pos]
857
+ for i, a in enumerate(ts):
858
+ for b in ts[i + 1:]:
859
+ shared[(a, b)] = shared.get((a, b), 0) + 1
860
+ for (a, b), n in sorted(shared.items()):
861
+ edges.append({"a": a, "b": b, "kind": "shared", "n": n, "computed": True})
862
+ # everything: facts spiral around their topic; sources at their topics' centre
863
+ every = []
864
+ golden = math.pi * (3 - math.sqrt(5))
865
+ for s in order:
866
+ cx, cy = pos[s]
867
+ for i, fid in enumerate(topics[s]["facts"]):
868
+ r = 18 + 7 * math.sqrt(i + 1)
869
+ every.append({"id": fid, "kind": "fact", "x": round(cx + r * math.cos(i * golden), 1),
870
+ "y": round(cy + r * math.sin(i * golden), 1)})
871
+ for i, name in enumerate(sorted(sources)):
872
+ ts = [pos[t] for t in sources[name]["topics"] if t in pos]
873
+ if ts:
874
+ x, y = sum(p[0] for p in ts) / len(ts), sum(p[1] for p in ts) / len(ts)
875
+ else:
876
+ x, y = 0.0, 0.0
877
+ r = 40 + 4 * (i % 7)
878
+ every.append({"id": name, "kind": "source", "x": round(x + r * math.cos(i * golden), 1),
879
+ "y": round(y + r * math.sin(i * golden), 1)})
880
+ return {"nodes": nodes, "edges": edges, "every": every}
881
+
882
+
883
+ # ------------------------------------------------------------ the documents
884
+
885
+ def doc_page(docs, rel, facts):
886
+ t = _text(docs / rel)
887
+ if t is None:
888
+ return None
889
+ pairs, body = split_frontmatter(t)
890
+ base = posixpath.dirname(rel)
891
+ page = {"title": first_heading(body) or posixpath.basename(rel),
892
+ "meta": pairs, "html": markdown(body, base, facts),
893
+ "_n": normalize(" ".join([first_heading(body) or "", rel,
894
+ " ".join(v for _k, v in pairs), body]))}
895
+ if re.match(r"^solutions/ANALYSIS_[^/]+\.md$", rel):
896
+ page["chain"] = {"steps": analysis_chain(body), "_stem": posixpath.basename(rel)[9:-3]}
897
+ return page
898
+
899
+
900
+ # The chain an analysis is read through (R24): why (Vision), what (use cases, flows and
901
+ # interfaces, risks), how (solution study, design and tests), then the merge brief and
902
+ # E2E script, then the Diary. Headings are the validator's own wherever it names them --
903
+ # ANALYSIS_SECTIONS, SECURITY_SECTION and every lens's risk section in DOMAINS -- and the
904
+ # template's otherwise; a heading matches by prefix, case-insensitively.
905
+ CHAIN_STEPS = ("vision", "use_cases", "flows", "risks", "solution", "design",
906
+ "merge_tests", "diary")
907
+
908
+
909
+ def _chain_headings():
910
+ named = {pair[0]: pair for pair in sdlc_core.ANALYSIS_SECTIONS}
911
+ risks = list(sdlc_core.SECURITY_SECTION)
912
+ for lens in sdlc_core.DOMAINS.values():
913
+ risks += [h for h in lens["risk_section"] if h not in risks]
914
+ return (("vision", named["## Objective"] + named["## Feature Vision"] + ("## Vision Alignment",)),
915
+ ("use_cases", ("## Use Cases",)),
916
+ ("flows", ("## Functional Spec", "## Interface Contract")),
917
+ ("risks", tuple(risks)),
918
+ ("solution", ("## Capability Ledger",) + named["## Impact"]),
919
+ ("design", named["## Action Plan"] + named["## Test Strategy"]),
920
+ ("diary", named["## Diary"]))
921
+
922
+
923
+ def chain_step(heading):
924
+ """The chain step a level-2 heading belongs to, or None (it stays where it is)."""
925
+ h = heading.strip().lower()
926
+ for step, aliases in _chain_headings():
927
+ if any(h.startswith(a.lower()) for a in aliases):
928
+ return step
929
+ return None
930
+
931
+
932
+ def analysis_chain(body):
933
+ """[{step, sections: [{title, id}]}] in chain order; sections as written, in
934
+ document order. The id is the one the formatter gives the heading."""
935
+ found = {s: [] for s in CHAIN_STEPS}
936
+ fenced = False
937
+ for ln in body.split("\n"):
938
+ if ln.strip().startswith("```"):
939
+ fenced = not fenced
940
+ continue
941
+ if fenced or not ln.startswith("## "):
942
+ continue
943
+ step = chain_step(ln)
944
+ m = _HEAD.match(ln)
945
+ if step and m:
946
+ found[step].append({"title": m.group(2), "id": _slugify(m.group(2))})
947
+ return [{"step": s, "sections": found[s]} for s in CHAIN_STEPS]
948
+
949
+
950
+ def link_companions(docs, pages):
951
+ """Each analysis's merge brief, E2E script and request record, where they exist,
952
+ and the project's North Star (R25)."""
953
+ north_star = None
954
+ vp = "vision/project_vision.md"
955
+ if vp in pages:
956
+ title, _body, is_ns = vision_lead(split_frontmatter(_text(docs / vp) or "")[1])
957
+ if is_ns:
958
+ north_star = {"path": vp, "id": _slugify(title)}
959
+ for rel, page in pages.items():
960
+ chain = page.get("chain")
961
+ if not chain:
962
+ continue
963
+ stem = chain.pop("_stem")
964
+ chain["north_star"] = north_star
965
+ chain["companions"] = {
966
+ key: path if path in pages else None
967
+ for key, path in (("merge", "solutions/MERGE_REVIEW_%s.md" % stem),
968
+ ("e2e", "solutions/E2E_TEST_%s.md" % stem),
969
+ ("request", "audit/requests/REQUEST_%s.md" % stem))}
970
+
971
+
972
+ def orientation_sources(docs):
973
+ """{role: docs-relative path} of what the agent reads at session start, from
974
+ `sdlc_core.orient_docs` -- the one list. Roles are matched by position: a fifth
975
+ source added there needs a role here, and the battery goes red until it has one."""
976
+ roles = ("readme", "manifest", "router", "register")
977
+ out = {}
978
+ for role, (_label, rel) in zip(roles, sdlc_core.orient_docs()):
979
+ rel = rel.split("/", 1)[1]
980
+ out[role] = rel if (docs / rel).is_file() else None
981
+ return out
982
+
983
+
984
+ def audit_pages(docs):
985
+ """Documents the agent reads that have a page but no place in the tree (R23)."""
986
+ out = []
987
+ oriented = [r for r in orientation_sources(docs).values() if r]
988
+ for rel in oriented + ["topics/INDEX.md", "audit/project_notes.md", "audit/reviews/REVIEW_LOG.md"]:
989
+ if (docs / rel).is_file():
990
+ out.append(rel)
991
+ aud = docs / "audit"
992
+ if aud.is_dir():
993
+ out += ["audit/" + p.name for p in sorted(aud.glob("HANDOFF_*.md"))]
994
+ req = aud / "requests"
995
+ if req.is_dir():
996
+ out += ["audit/requests/" + p.name for p in sorted(req.glob("*.md"))]
997
+ return out
998
+
999
+
1000
+ def build(project, docs, name):
1001
+ lens = project_lens(project, name)
1002
+ kb = build_kb(docs)
1003
+ fact_ids = set(kb["facts"]) if kb else None
1004
+ catalog = knowledge.memory_records(docs)
1005
+ paths = [r[0] for r in catalog]
1006
+ pages = {}
1007
+ for rel in sorted(set(paths + audit_pages(docs))):
1008
+ page = doc_page(docs, rel, fact_ids)
1009
+ if page is not None:
1010
+ pages[rel] = page
1011
+ link_companions(docs, pages)
1012
+ search = [{"k": "doc", "path": rel, "title": p["title"], "n": p.pop("_n")}
1013
+ for rel, p in sorted(pages.items())]
1014
+
1015
+ tree = {}
1016
+ for rel in paths:
1017
+ if rel in pages:
1018
+ tree.setdefault(posixpath.dirname(rel), []).append(
1019
+ {"path": rel, "title": pages[rel]["title"]})
1020
+ tree = [{"folder": f, "docs": sorted(d, key=lambda e: e["path"])} for f, d in sorted(tree.items())]
1021
+
1022
+ work = build_work(project, docs, fact_ids)
1023
+ units = build_units(project, lens) if units_page(lens) else None
1024
+ rules = build_rules(docs, pages)
1025
+ home = build_home(docs, pages, work, kb, fact_ids)
1026
+ head = sdlc_core.git_head(project) if sdlc_core.git_available(project) else ""
1027
+ changed = sdlc_core.git_changed_since(project, "HEAD", name) if head else None
1028
+ core = {
1029
+ "meta": {"generated": datetime.now().strftime("%Y-%m-%d %H:%M"),
1030
+ "git": bool(head), "commit": head, "changed": len(changed or []),
1031
+ "docs_dir": name, "lens": lens, "units_page": units_page(lens),
1032
+ "has_kb": bool(kb), "has_sources": bool(kb and kb["sources"])},
1033
+ "home": home, "work": work, "units": units, "rules": rules,
1034
+ "guides": build_guides(docs, pages), "tree": tree,
1035
+ "chronology": home["lately_all"], "search": search,
1036
+ }
1037
+ home.pop("lately_all")
1038
+ if kb:
1039
+ kb["search"] = ([{"k": "topic", "id": s, "title": kb["topics"][s]["name"],
1040
+ "n": normalize(" ".join([s, kb["topics"][s]["name"],
1041
+ " ".join(kb["topics"][s]["aka"]),
1042
+ kb["topics"][s]["description"]]))}
1043
+ for s in kb["order"]]
1044
+ + [{"k": "fact", "id": fid, "title": f["text"], "n": normalize(f["text"]),
1045
+ "statement": f["statement"]}
1046
+ for fid, f in kb["facts"].items()])
1047
+ return core, pages, kb
1048
+
1049
+
1050
+ def build_work(project, docs, facts):
1051
+ cards = []
1052
+ for p, meta in sdlc_core.list_workstreams(project):
1053
+ card = {"name": _v(meta, "workstream"), "group": status_group(meta.get("status")),
1054
+ "path": p.relative_to(docs).as_posix()}
1055
+ for key in ("status", "level", "since", "updated", "branch", "next"):
1056
+ if _v(meta, key):
1057
+ card[key] = _v(meta, key)
1058
+ cards.append(card)
1059
+ cards.sort(key=lambda c: c["name"].lower())
1060
+ cards.sort(key=lambda c: c.get("updated", ""), reverse=True)
1061
+ cards.sort(key=lambda c: (c["group"], not c.get("updated")))
1062
+ notes = None
1063
+ np = docs / "audit" / sdlc_core.PROJECT_NOTES
1064
+ if np.is_file():
1065
+ t = _text(np)
1066
+ if t is not None:
1067
+ notes = {"path": "audit/" + sdlc_core.PROJECT_NOTES, "html": markdown(t, "audit", facts)}
1068
+ register = "audit/handoff.md" if (docs / "audit" / "handoff.md").is_file() else None
1069
+ return {"cards": cards, "notes": notes, "register": register}
1070
+
1071
+
1072
+ def build_units(project, lens):
1073
+ default = sdlc_core.project_default_domain(project)
1074
+ units = []
1075
+ for p, meta, text in sdlc_core.list_analyses(project):
1076
+ _pairs, body = split_frontmatter(text)
1077
+ u = {"name": first_heading(body) or p.stem, "group": status_group(meta.get("status")),
1078
+ "lens": sdlc_core.resolve_domain(meta, default)[0],
1079
+ "path": "solutions/" + p.name}
1080
+ for key, field in (("id", "id"), ("status", "status"), ("level", "level"),
1081
+ ("start", "start_date"), ("end", "end_date")):
1082
+ if _v(meta, field):
1083
+ u[key] = _v(meta, field)
1084
+ if "id" not in u and _v(meta, "feature"):
1085
+ u["id"] = _v(meta, "feature")
1086
+ units.append(u)
1087
+ units.sort(key=lambda u: (u.get("id", ""), u["path"]))
1088
+ return units
1089
+
1090
+
1091
+ def build_rules(docs, pages):
1092
+ def entries(folder, pattern):
1093
+ d = docs / folder
1094
+ return sorted(folder + "/" + p.name for p in d.glob(pattern)
1095
+ if p.name != "INDEX.md") if d.is_dir() else []
1096
+
1097
+ def item(rel):
1098
+ meta = dict((k.lower(), v) for k, v in pages[rel]["meta"])
1099
+ return {"path": rel, "title": pages[rel]["title"], "when": meta.get("description", "")}
1100
+
1101
+ vision = [item(r) for r in entries("vision", "*.md") if r in pages]
1102
+ for v in vision:
1103
+ t = _text(docs / v["path"]) or ""
1104
+ pairs, body = split_frontmatter(t)
1105
+ v["status"] = vision_status(pairs, body) or ""
1106
+ decisions = [item(r) for r in entries("architecture", "*.md") if r in pages]
1107
+ return {"vision": vision, "decisions": decisions,
1108
+ "manifest": "INDEX.md" if "INDEX.md" in pages else None}
1109
+
1110
+
1111
+ def build_guides(docs, pages):
1112
+ """The guides page (R26): the router, then operative guides (when to read each, status
1113
+ as declared), then code-comprehension guides (`source_kind: code`)."""
1114
+ ref = docs / "reference"
1115
+ operative, comprehension = [], []
1116
+ for p in sorted(ref.glob("GUIDE_*.md")) if ref.is_dir() else []:
1117
+ rel = "reference/" + p.name
1118
+ if rel not in pages:
1119
+ continue
1120
+ meta = dict((k.lower(), v) for k, v in pages[rel]["meta"])
1121
+ g = {"path": rel, "title": pages[rel]["title"], "when": meta.get("description", ""),
1122
+ "status": meta.get("status", "")}
1123
+ (comprehension if meta.get("source_kind", "").strip().lower() == "code"
1124
+ else operative).append(g)
1125
+ return {"router": "reference/INDEX.md" if "reference/INDEX.md" in pages else None,
1126
+ "operative": operative, "comprehension": comprehension}
1127
+
1128
+
1129
+ def build_home(docs, pages, work, kb, facts):
1130
+ readme = pages.get("README.md")
1131
+ title, intro = None, ""
1132
+ t = _text(docs / "README.md") if readme else None
1133
+ if t is not None:
1134
+ _pairs, body = split_frontmatter(t)
1135
+ title = first_heading(body)
1136
+ intro = markdown(opening_paragraph(body), "", facts)
1137
+ vision = None
1138
+ vp = "vision/project_vision.md"
1139
+ if (docs / vp).is_file():
1140
+ vt = _text(docs / vp)
1141
+ if vt is not None:
1142
+ pairs, body = split_frontmatter(vt)
1143
+ st, sb, is_ns = vision_lead(body)
1144
+ vision = {"path": vp, "status": vision_status(pairs, body), "north_star": is_ns,
1145
+ "section_title": st, "section_html": markdown(sb or "", "vision", facts)}
1146
+ events = chronology(docs, pages)
1147
+ known = []
1148
+ if kb:
1149
+ for s in kb["order"]:
1150
+ if kb["topics"][s].get("area") == s:
1151
+ known.append({"slug": s, "name": kb["topics"][s]["name"],
1152
+ "topics": [o for o in kb["order"]
1153
+ if o != s and kb["topics"][o].get("area") == s]})
1154
+ links = {role: (rel if rel in pages else None)
1155
+ for role, rel in orientation_sources(docs).items()}
1156
+ links["work"] = "work"
1157
+ if work["notes"]:
1158
+ links["notes"] = work["notes"]["path"]
1159
+ if kb:
1160
+ links["topics"] = "map"
1161
+ return {"title": title or "", "intro_html": intro, "vision": vision, "links": links,
1162
+ "open": [{k: c[k] for k in ("name", "status", "next", "path") if k in c}
1163
+ for c in work["cards"]],
1164
+ "lately": events[:10], "lately_all": events, "known": known}
1165
+
1166
+
1167
+ SPIKE_OUTCOME = ("## Answer / Outcome",)
1168
+
1169
+
1170
+ def chronology(docs, pages):
1171
+ """Dated events the documents declare, most recent first: {date, kind, label,
1172
+ path}. Which lines are events is F-073's rule (R22), read with the validator's
1173
+ own readers -- `section_lines` + `entry_openers` for Diary entries, spike
1174
+ outcomes and project notes, `request_entries`, `parse_review_log`, `adr_date`,
1175
+ `knowledge.frontmatter_date` -- so the page and `validate` cannot disagree. No
1176
+ analysis start or end date, no state, nothing from git. `path` is the page the
1177
+ event opens, or None when the site has no page for it."""
1178
+ events = []
1179
+
1180
+ def add(date, kind, label, rel, source=None):
1181
+ e = {"date": date, "kind": kind, "label": label,
1182
+ "path": rel if rel in pages else None}
1183
+ if source:
1184
+ e["source"] = source # opens the source's own page (#/source/<name>)
1185
+ events.append(e)
1186
+
1187
+ def openers(rel, kind, aliases=None):
1188
+ text = _text(docs / rel) if (docs / rel).is_file() else None
1189
+ if text is None:
1190
+ return
1191
+ if aliases is not None:
1192
+ section = sdlc_core.section_lines(text, aliases)
1193
+ if section is None:
1194
+ return
1195
+ text = section[1]
1196
+ for date, label, _ in sdlc_core.entry_openers(text):
1197
+ add(date, kind, label, rel)
1198
+
1199
+ def title_of(rel, text):
1200
+ return pages[rel]["title"] if rel in pages else (
1201
+ first_heading(split_frontmatter(text)[1]) or posixpath.basename(rel))
1202
+
1203
+ for p in sorted(docs.glob("solutions/ANALYSIS_*.md")):
1204
+ text = _text(p)
1205
+ # a shadow mirror is not an analysis to `validate` (list_analyses): not here either
1206
+ if text is not None and not sdlc_core.is_shadow(p, (text.splitlines() or [""])[0]):
1207
+ openers("solutions/" + p.name, "diary", sdlc_core.DIARY_SECTION)
1208
+ # spike outcomes: SPIKE_* in the code/kb/course lenses, RESEARCH_* in marketing
1209
+ for rel in sorted(["solutions/" + p.name for p in docs.glob("solutions/SPIKE_*.md")]
1210
+ + ["spikes/" + p.name for p in docs.glob("spikes/RESEARCH_*.md")]):
1211
+ openers(rel, "spike", SPIKE_OUTCOME)
1212
+ openers("audit/" + sdlc_core.PROJECT_NOTES, "project_note")
1213
+ for p in sorted(docs.glob("audit/requests/REQUEST_*.md")):
1214
+ rel = "audit/requests/" + p.name
1215
+ text = _text(p)
1216
+ for rid, date, label, _ in sdlc_core.request_entries(text or "")[0]:
1217
+ add(date, "request", "%s — %s" % (rid, label), rel)
1218
+ rel = "audit/reviews/REVIEW_LOG.md"
1219
+ text = _text(docs / rel) if (docs / rel).is_file() else None
1220
+ if text is not None:
1221
+ for row in sdlc_core.parse_review_log(text)[0]:
1222
+ add(row["date"], "review", "%s — %s: %s" % (
1223
+ row["doc_key"], row["tier"], row["verdict"]), rel)
1224
+ for p in sorted(docs.glob("architecture/ADR_*.md")):
1225
+ rel = "architecture/" + p.name
1226
+ text = _text(p)
1227
+ date = sdlc_core.adr_date(p.name, text or "")
1228
+ if date:
1229
+ add(date, "decision", title_of(rel, text or ""), rel)
1230
+ for folder, kind, suffix in (("corpus/notes", "note", ".md"),
1231
+ ("corpus/given", "source", ".meta.md")):
1232
+ for p in sorted(docs.glob(folder + "/*" + suffix)):
1233
+ rel = folder + "/" + p.name
1234
+ text = _text(p)
1235
+ if text is None:
1236
+ continue
1237
+ date = knowledge.frontmatter_date(
1238
+ sdlc_core.load_frontmatter(text.splitlines()))
1239
+ if not date:
1240
+ continue
1241
+ if kind == "source":
1242
+ name = p.name[:-len(suffix)]
1243
+ add(date, kind, name, rel, source=name)
1244
+ else:
1245
+ add(date, kind, title_of(rel, text), rel)
1246
+ # Most recent first. Within one day a document's later entry is the more recent
1247
+ # one, so the reading order is reversed too. Entries carry no time of day, so
1248
+ # between DIFFERENT documents on one day the order is fixed but not a time order:
1249
+ # the kinds come in the reverse of the reading order above.
1250
+ ranked = sorted(enumerate(events), key=lambda ne: (ne[1]["date"], ne[0]), reverse=True)
1251
+ return [e for _, e in ranked]
1252
+
1253
+
1254
+ # ----------------------------------------------------------------- writing
1255
+
1256
+ def _js(var, obj):
1257
+ s = json.dumps(obj, ensure_ascii=True, separators=(",", ":"))
1258
+ return "window.%s = %s;\n" % (var, s.replace("<", "\\u003c"))
1259
+
1260
+
1261
+ def check_out(out, docs):
1262
+ out = out.resolve()
1263
+ d = docs.resolve()
1264
+ if out == d or d in out.parents:
1265
+ raise SiteError("--out %s is inside the docs root %s: the site is written outside it."
1266
+ % (out, d))
1267
+ if out.exists() and not out.is_dir():
1268
+ raise SiteError("--out %s exists and is not a folder." % out)
1269
+ if out.is_dir() and any(out.iterdir()) and not (out / MARKER).is_file():
1270
+ raise SiteError("--out %s is not empty and was not written by this generator: "
1271
+ "refusing to write over it." % out)
1272
+ return out
1273
+
1274
+
1275
+ def write_site(out, core, pages, kb):
1276
+ out.mkdir(parents=True, exist_ok=True)
1277
+ for rel in PAGE_FILES + ("data",):
1278
+ p = out / rel
1279
+ if p.is_dir():
1280
+ shutil.rmtree(p)
1281
+ elif p.exists():
1282
+ p.unlink()
1283
+ (out / MARKER).write_text("Written by docs_site.py; regenerated on every run.\n",
1284
+ encoding="utf-8", newline="\n")
1285
+ for name in PAGE_FILES:
1286
+ shutil.copyfile(PAGE_DIR / name, out / name)
1287
+ data = out / "data"
1288
+ data.mkdir()
1289
+ (data / "core.js").write_text(_js("DOCS_SITE_CORE", core), encoding="utf-8", newline="\n")
1290
+ (data / "docs.js").write_text(_js("DOCS_SITE_DOCS", pages), encoding="utf-8", newline="\n")
1291
+ if kb:
1292
+ (data / "kb.js").write_text(_js("DOCS_SITE_KB", kb), encoding="utf-8", newline="\n")
1293
+
1294
+
1295
+ def git_ignored(project, out):
1296
+ if not sdlc_core.git_available(project):
1297
+ return None
1298
+ try:
1299
+ out.relative_to(project.resolve())
1300
+ except ValueError:
1301
+ return None # outside the repository: nothing to commit
1302
+ try:
1303
+ r = subprocess.run(["git", "check-ignore", "-q", str(out)], cwd=str(project),
1304
+ capture_output=True, text=True, timeout=10)
1305
+ return {0: True, 1: False}.get(r.returncode)
1306
+ except Exception:
1307
+ return None
1308
+
1309
+
1310
+ def _shown(path, docs):
1311
+ try:
1312
+ return Path(path).relative_to(docs).as_posix()
1313
+ except ValueError:
1314
+ return str(path)
1315
+
1316
+
1317
+ def main(argv=None):
1318
+ ap = argparse.ArgumentParser(
1319
+ prog="docs_site.py",
1320
+ description="Generate a static, read-only site of what the agent reads in this project.")
1321
+ ap.add_argument("--root", help="project root (default: discovered from the current folder)")
1322
+ ap.add_argument("--docs-dir", dest="docs_dir", help="docs root name (ai_docs or mkt_docs)")
1323
+ ap.add_argument("--out", help="output folder (default: <project>/%s)" % DEFAULT_OUT)
1324
+ ap.add_argument("--open", action="store_true", help="open the site in the browser")
1325
+ args = ap.parse_args(argv)
1326
+ del _UNREADABLE[:]
1327
+ try:
1328
+ project, docs, name = resolve_root(args)
1329
+ out = check_out(Path(args.out) if args.out else project / DEFAULT_OUT, docs)
1330
+ except SiteError as exc:
1331
+ print("[ERROR] %s" % exc)
1332
+ return 2
1333
+ if not PAGE_DIR.is_dir():
1334
+ print("[ERROR] the page files are missing: %s (reinstall the skill)" % PAGE_DIR)
1335
+ return 2
1336
+ try:
1337
+ with _GuardedReads():
1338
+ core, pages, kb = build(project, docs, name)
1339
+ except OSError as exc:
1340
+ # a reader that bypasses read_text (raw bytes) met an unreadable file
1341
+ _note_unreadable(getattr(exc, "filename", None) or "?", exc)
1342
+ for path, err in _UNREADABLE:
1343
+ print("[ERROR] unreadable: %s (%s)" % (_shown(path, docs), err))
1344
+ print("[ERROR] the site was not written: fix the file's permissions and run again.")
1345
+ return 1
1346
+ write_site(out, core, pages, kb)
1347
+ print("[ok] docs site written: %s (%d documents%s, from %s)" % (
1348
+ out / "index.html", len(pages),
1349
+ ", %d topics, %d facts" % (len(kb["order"]), len(kb["facts"])) if kb else "", docs))
1350
+ for path, exc in _UNREADABLE:
1351
+ print("[ERROR] unreadable: %s (%s)" % (_shown(path, docs), exc))
1352
+ if git_ignored(project, out) is False:
1353
+ print("[note] %s is not git-ignored: add it to .gitignore so the site's copy of "
1354
+ "your documents is not committed." % out)
1355
+ if args.open:
1356
+ import webbrowser
1357
+ webbrowser.open((out / "index.html").as_uri())
1358
+ return 1 if _UNREADABLE else 0
1359
+
1360
+
1361
+ if __name__ == "__main__":
1362
+ sys.exit(main())