@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.
- package/CHANGELOG.md +58 -0
- package/README.md +6 -6
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/skills/kb-agentic-skill/ENFORCEMENT.md +15 -3
- package/skills/kb-agentic-skill/SKILL.md +8 -7
- package/skills/kb-agentic-skill/distillation.md +51 -1
- package/skills/kb-agentic-skill/docs_site/app.js +9 -3
- package/skills/kb-agentic-skill/guides.md +7 -1
- package/skills/kb-agentic-skill/memory.md +1 -1
- package/skills/kb-agentic-skill/portability.md +34 -0
- package/skills/kb-agentic-skill/reconciliation.md +2 -0
- package/skills/kb-agentic-skill/review.md +28 -3
- package/skills/kb-agentic-skill/routing.md +4 -0
- package/skills/kb-agentic-skill/scripts/docs_site.py +94 -8
- package/skills/kb-agentic-skill/scripts/knowledge.py +519 -17
- package/skills/kb-agentic-skill/scripts/sdlc_core.py +581 -70
- package/skills/kb-agentic-skill/templates.md +27 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
1238
|
-
|
|
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
|
-
|
|
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())
|