@antoneeo/kb-agentic-skill 1.26.1 → 1.31.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.
@@ -33,6 +33,7 @@ import re
33
33
  import shutil
34
34
  import subprocess
35
35
  import sys
36
+ import zipfile
36
37
  import unicodedata
37
38
  import urllib.parse
38
39
  from datetime import datetime
@@ -767,6 +768,8 @@ def build_sources(docs, rows_of):
767
768
  name = mp.name[:-len(".meta.md")]
768
769
  t = _text(mp) or ""
769
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
770
773
  through = _v(meta, "extracted_through")
771
774
  parsed = knowledge.kb_parse_extracted_through(through) if through else None
772
775
  if not through:
@@ -947,6 +950,21 @@ def analysis_chain(body):
947
950
  return [{"step": s, "sections": found[s]} for s in CHAIN_STEPS]
948
951
 
949
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
+
950
968
  def link_companions(docs, pages):
951
969
  """Each analysis's merge brief, E2E script and request record, where they exist,
952
970
  and the project's North Star (R25)."""
@@ -962,11 +980,8 @@ def link_companions(docs, pages):
962
980
  continue
963
981
  stem = chain.pop("_stem")
964
982
  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))}
983
+ chain["companions"] = {key: path if path in pages else None
984
+ for key, path in companion_paths(stem)}
970
985
 
971
986
 
972
987
  def orientation_sources(docs):
@@ -1055,6 +1070,7 @@ def build_work(project, docs, facts):
1055
1070
  for key in ("status", "level", "since", "updated", "branch", "next"):
1056
1071
  if _v(meta, key):
1057
1072
  card[key] = _v(meta, key)
1073
+ card.update(verify_links(docs, p.stem[len("HANDOFF_"):]))
1058
1074
  cards.append(card)
1059
1075
  cards.sort(key=lambda c: c["name"].lower())
1060
1076
  cards.sort(key=lambda c: c.get("updated", ""), reverse=True)
@@ -1083,8 +1099,12 @@ def build_units(project, lens):
1083
1099
  u[key] = _v(meta, field)
1084
1100
  if "id" not in u and _v(meta, "feature"):
1085
1101
  u["id"] = _v(meta, "feature")
1102
+ u.update(verify_links(sdlc_core.ai_path(project), p.stem[len("ANALYSIS_"):]))
1086
1103
  units.append(u)
1104
+ # newest first by declared start date (owner, 2026-10-19); undated last, by id
1087
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"))
1088
1108
  return units
1089
1109
 
1090
1110
 
@@ -1204,7 +1224,14 @@ def chronology(docs, pages):
1204
1224
  text = _text(p)
1205
1225
  # a shadow mirror is not an analysis to `validate` (list_analyses): not here either
1206
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)
1207
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"])
1208
1235
  # spike outcomes: SPIKE_* in the code/kb/course lenses, RESEARCH_* in marketing
1209
1236
  for rel in sorted(["solutions/" + p.name for p in docs.glob("solutions/SPIKE_*.md")]
1210
1237
  + ["spikes/" + p.name for p in docs.glob("spikes/RESEARCH_*.md")]):
@@ -1234,10 +1261,12 @@ def chronology(docs, pages):
1234
1261
  text = _text(p)
1235
1262
  if text is None:
1236
1263
  continue
1237
- date = knowledge.frontmatter_date(
1238
- sdlc_core.load_frontmatter(text.splitlines()))
1264
+ fm = sdlc_core.load_frontmatter(text.splitlines())
1265
+ date = knowledge.frontmatter_date(fm)
1239
1266
  if not date:
1240
1267
  continue
1268
+ if kind == "source" and knowledge.kb_is_tombstone(fm):
1269
+ continue
1241
1270
  if kind == "source":
1242
1271
  name = p.name[:-len(suffix)]
1243
1272
  add(date, kind, name, rel, source=name)
@@ -1272,6 +1301,29 @@ def check_out(out, docs):
1272
1301
  return out
1273
1302
 
1274
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
+
1275
1327
  def write_site(out, core, pages, kb):
1276
1328
  out.mkdir(parents=True, exist_ok=True)
1277
1329
  for rel in PAGE_FILES + ("data",):
@@ -1307,6 +1359,30 @@ def git_ignored(project, out):
1307
1359
  return None
1308
1360
 
1309
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
+
1310
1386
  def _shown(path, docs):
1311
1387
  try:
1312
1388
  return Path(path).relative_to(docs).as_posix()
@@ -1322,11 +1398,14 @@ def main(argv=None):
1322
1398
  ap.add_argument("--docs-dir", dest="docs_dir", help="docs root name (ai_docs or mkt_docs)")
1323
1399
  ap.add_argument("--out", help="output folder (default: <project>/%s)" % DEFAULT_OUT)
1324
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")
1325
1403
  args = ap.parse_args(argv)
1326
1404
  del _UNREADABLE[:]
1327
1405
  try:
1328
1406
  project, docs, name = resolve_root(args)
1329
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
1330
1409
  except SiteError as exc:
1331
1410
  print("[ERROR] %s" % exc)
1332
1411
  return 2
@@ -1349,9 +1428,16 @@ def main(argv=None):
1349
1428
  ", %d topics, %d facts" % (len(kb["order"]), len(kb["facts"])) if kb else "", docs))
1350
1429
  for path, exc in _UNREADABLE:
1351
1430
  print("[ERROR] unreadable: %s (%s)" % (_shown(path, docs), exc))
1352
- if git_ignored(project, out) is False:
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:
1353
1436
  print("[note] %s is not git-ignored: add it to .gitignore so the site's copy of "
1354
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)
1355
1441
  if args.open:
1356
1442
  import webbrowser
1357
1443
  webbrowser.open((out / "index.html").as_uri())