@antoneeo/course-creator 0.1.0-beta.1 → 0.1.0-beta.13

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