@antoneeo/mkt-agentic-sdlc-skill 0.23.0 → 0.26.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 +37 -0
- package/README.md +3 -3
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/skills/mkt-agentic-sdlc/ENFORCEMENT.md +20 -8
- package/skills/mkt-agentic-sdlc/SKILL.md +3 -2
- package/skills/mkt-agentic-sdlc/guides.md +7 -1
- package/skills/mkt-agentic-sdlc/memory.md +1 -1
- package/skills/mkt-agentic-sdlc/routing.md +4 -0
- package/skills/mkt-agentic-sdlc/scripts/docs_site.py +72 -3
- package/skills/mkt-agentic-sdlc/scripts/knowledge.py +519 -17
- package/skills/mkt-agentic-sdlc/scripts/mkt_check.py +1 -1
- package/skills/mkt-agentic-sdlc/scripts/sdlc_core.py +569 -69
|
@@ -5,6 +5,8 @@ Extracted from the KB overlay; the kb_* API and stored formats remain compatible
|
|
|
5
5
|
Every distribution ships this module alongside its entry point and sdlc_core.py.
|
|
6
6
|
"""
|
|
7
7
|
import argparse
|
|
8
|
+
import contextlib
|
|
9
|
+
import io
|
|
8
10
|
import os
|
|
9
11
|
import hashlib
|
|
10
12
|
import ntpath
|
|
@@ -112,6 +114,22 @@ def kb_claim_id(source_path, locator, qty_key=""):
|
|
|
112
114
|
# reviewer-comment shape ("states X, that Y, and that Z").
|
|
113
115
|
KB_BUNDLE_HINTS = (", and that ", "; ", ", while ", " and also ")
|
|
114
116
|
|
|
117
|
+
# F-080: `about` holds relations joined by ';', and one reserved predicate whose
|
|
118
|
+
# target is the Standalone ANALYSIS recording a deferred fix of code that does
|
|
119
|
+
# not yet honor the claim. The mark has no reverse direction: nothing lists,
|
|
120
|
+
# counts or selects claims by it -- only the claim checks and export read it.
|
|
121
|
+
KB_NOT_HONORED = "not-yet-honored-by"
|
|
122
|
+
KB_ABOUT_RE = re.compile(r"^([a-z0-9-]+) -> ([a-z0-9-]+)$")
|
|
123
|
+
KB_NOT_HONORED_RE = re.compile(r"^%s -> ((?:ANALYSIS|SHADOW)_[A-Za-z0-9_-]+)$"
|
|
124
|
+
% KB_NOT_HONORED)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def kb_about_parts(cell):
|
|
128
|
+
"""The ';'-joined parts of an `about` cell; [] for '-' or empty."""
|
|
129
|
+
if cell.strip() in ("", "-"):
|
|
130
|
+
return []
|
|
131
|
+
return [part.strip() for part in cell.split(";")]
|
|
132
|
+
|
|
115
133
|
|
|
116
134
|
def kb_unquote(value):
|
|
117
135
|
"""Strip ONE pair of wrapping YAML quotes (then re-strip whitespace).
|
|
@@ -363,6 +381,7 @@ def kb_check_claims(root):
|
|
|
363
381
|
src_cache[path_s] = (t, t is not None and t.is_file())
|
|
364
382
|
return src_cache[path_s]
|
|
365
383
|
per_file_rows = []
|
|
384
|
+
marked = [] # (where, row, [ANALYSIS_ targets]) -- F-080
|
|
366
385
|
for p in sorted(topics.glob("*.md")):
|
|
367
386
|
rel = "topics/" + p.name
|
|
368
387
|
text = sdlc_core.read_text(p)
|
|
@@ -445,11 +464,22 @@ def kb_check_claims(root):
|
|
|
445
464
|
except ValueError as e:
|
|
446
465
|
errors.append("%s: %s" % (where, e))
|
|
447
466
|
qk = None
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
467
|
+
marks = []
|
|
468
|
+
for part in kb_about_parts(row["about"]):
|
|
469
|
+
mark = KB_NOT_HONORED_RE.match(part)
|
|
470
|
+
rel_m = KB_ABOUT_RE.match(part)
|
|
471
|
+
if mark:
|
|
472
|
+
marks.append(mark.group(1))
|
|
473
|
+
elif part.split(" -> ")[0].strip() == KB_NOT_HONORED:
|
|
474
|
+
errors.append("%s: about %r: '%s' needs an ANALYSIS_<name> "
|
|
475
|
+
"target (Hybrid: SHADOW_<doc_key>, unversioned) -- the "
|
|
476
|
+
"analysis or design that records the deferred "
|
|
477
|
+
"fix" % (where, part, KB_NOT_HONORED))
|
|
478
|
+
elif not rel_m:
|
|
479
|
+
errors.append("%s: about must be '<predicate> -> <slug>' "
|
|
480
|
+
"parts joined by ';', or '-': %r" % (where, part))
|
|
481
|
+
if marks:
|
|
482
|
+
marked.append((where, row, marks))
|
|
453
483
|
# --- atomicity hint (F-047): a row atomic as provenance can be
|
|
454
484
|
# plural as meaning. Text cannot PROVE plurality, so this is a
|
|
455
485
|
# note — never a warning or an error, never the exit code.
|
|
@@ -579,9 +609,100 @@ def kb_check_claims(root):
|
|
|
579
609
|
"name %s back; one flipped cell must never "
|
|
580
610
|
"silently end a disagreement"
|
|
581
611
|
% (where, t, row["id"]))
|
|
612
|
+
kb_check_marks(root, marked, per_file_rows, errors)
|
|
582
613
|
return errors, warnings, notes
|
|
583
614
|
|
|
584
615
|
|
|
616
|
+
def kb_check_marks(root, marked, per_file_rows, errors):
|
|
617
|
+
"""F-080: each not-yet-honored mark names an ANALYSIS that exists in this
|
|
618
|
+
project and cites the claim id -- or the id of a row this claim succeeds,
|
|
619
|
+
since supersession copies the mark and edits no analysis. F-081: a Hybrid
|
|
620
|
+
mark names SHADOW_<doc_key>, and the newest shadow of that key is read."""
|
|
621
|
+
if not marked:
|
|
622
|
+
return
|
|
623
|
+
preds = {} # successor id -> ids it supersedes
|
|
624
|
+
for _, rows in per_file_rows:
|
|
625
|
+
for row in rows:
|
|
626
|
+
m = re.match(r"^SUPERSEDED ([0-9a-f, ]+)$", row["state"])
|
|
627
|
+
if row["id"] and m:
|
|
628
|
+
for t in [x.strip() for x in m.group(1).split(",") if x.strip()]:
|
|
629
|
+
preds.setdefault(t, set()).add(row["id"])
|
|
630
|
+
texts = {}
|
|
631
|
+
for where, row, targets in marked:
|
|
632
|
+
ids, todo = set(), [row["id"]] if row["id"] else []
|
|
633
|
+
while todo:
|
|
634
|
+
cid = todo.pop()
|
|
635
|
+
if cid not in ids:
|
|
636
|
+
ids.add(cid)
|
|
637
|
+
todo.extend(preds.get(cid, ()))
|
|
638
|
+
for target in targets:
|
|
639
|
+
if target not in texts:
|
|
640
|
+
texts[target] = _kb_mark_target(root, target)
|
|
641
|
+
rel, text, stray = texts[target]
|
|
642
|
+
if text is None and target.startswith("SHADOW_"):
|
|
643
|
+
errors.append("%s: %s -> %s: no %s in this project%s. Export the "
|
|
644
|
+
"shadow from the accepted devPNT version, or remove "
|
|
645
|
+
"the marker if it did not come from this project -- "
|
|
646
|
+
"it describes its origin's code"
|
|
647
|
+
% (where, KB_NOT_HONORED, target, rel,
|
|
648
|
+
" (%s is not named _vX.Y)" % stray if stray else ""))
|
|
649
|
+
elif text is None:
|
|
650
|
+
errors.append("%s: %s -> %s: no %s in this project. Remove the "
|
|
651
|
+
"marker if it did not come from this project -- it "
|
|
652
|
+
"describes its origin's code" % (where, KB_NOT_HONORED,
|
|
653
|
+
target, rel))
|
|
654
|
+
elif ids and not any(re.search(r"(?<![0-9a-f])%s(?![0-9a-f])" % cid, text)
|
|
655
|
+
for cid in ids):
|
|
656
|
+
errors.append("%s: %s does not cite claim %s -- the analysis or "
|
|
657
|
+
"design that records a deferred fix cites the id it "
|
|
658
|
+
"defers"
|
|
659
|
+
% (where, rel, row["id"]))
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _kb_mark_target(root, target):
|
|
663
|
+
"""(rel, text or None, stray name or None) for a marker target. ANALYSIS_x
|
|
664
|
+
is solutions/x.md; SHADOW_k is the newest solutions/SHADOW_k_vX.Y.md, and
|
|
665
|
+
when none exists a file of the same exact key with another suffix is
|
|
666
|
+
reported as the stray that was not read (F-081)."""
|
|
667
|
+
if not target.startswith("SHADOW_"):
|
|
668
|
+
rel = "solutions/%s.md" % target
|
|
669
|
+
path = sdlc_core.confine_under(root, rel)
|
|
670
|
+
return rel, (sdlc_core.read_text(path)
|
|
671
|
+
if path is not None and path.is_file() else None), None
|
|
672
|
+
key = target[len("SHADOW_"):]
|
|
673
|
+
sol = sdlc_core.confine_under(root, "solutions")
|
|
674
|
+
path = sdlc_core.newest_shadow(sol, key) if sol is not None else None
|
|
675
|
+
if path is not None and sdlc_core.confine_under(root, "solutions/" + path.name):
|
|
676
|
+
return "solutions/" + path.name, sdlc_core.read_text(path), None
|
|
677
|
+
stray = sdlc_core.stray_shadow(sol, key) if sol is not None else None
|
|
678
|
+
return "solutions/SHADOW_%s_v<X.Y>.md" % key, None, stray
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def kb_drop_marks(text):
|
|
682
|
+
"""(text, dropped): the topic text with every not-yet-honored part removed
|
|
683
|
+
from claim rows' `about` cells. Every other byte is kept -- other cells keep
|
|
684
|
+
their spacing, other lines are untouched (F-080: a bundle describes no code)."""
|
|
685
|
+
rows, _ = kb_parse_claims(text)
|
|
686
|
+
lines = text.split("\n")
|
|
687
|
+
col = 1 + CLAIM_COLUMNS.index("about")
|
|
688
|
+
dropped = False
|
|
689
|
+
for row in rows:
|
|
690
|
+
parts = kb_about_parts(row["about"])
|
|
691
|
+
keep = [p for p in parts if not KB_NOT_HONORED_RE.match(p)]
|
|
692
|
+
if len(keep) == len(parts):
|
|
693
|
+
continue
|
|
694
|
+
# The parser strips the pipes at both ends, so a row with no trailing
|
|
695
|
+
# pipe is legal: counted from the left, the about cell sits at `col`
|
|
696
|
+
# either way.
|
|
697
|
+
segs = lines[row["_line"] - 1].split("|")
|
|
698
|
+
if len(segs) not in (len(CLAIM_COLUMNS) + 1, len(CLAIM_COLUMNS) + 2):
|
|
699
|
+
continue
|
|
700
|
+
segs[col] = " %s " % ("; ".join(keep) or "-")
|
|
701
|
+
lines[row["_line"] - 1] = "|".join(segs)
|
|
702
|
+
dropped = True
|
|
703
|
+
return "\n".join(lines), dropped
|
|
704
|
+
|
|
705
|
+
|
|
585
706
|
def kb_check_locator(target, loc, where, errors, cache=None):
|
|
586
707
|
"""A locator must address an existing span of the stored bytes.
|
|
587
708
|
|
|
@@ -912,6 +1033,10 @@ def kb_build_corpus_index(root):
|
|
|
912
1033
|
for meta_p in sorted(given.glob("*.meta.md")):
|
|
913
1034
|
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
|
|
914
1035
|
orig = meta_p.name[:-len(".meta.md")]
|
|
1036
|
+
if kb_is_tombstone(meta):
|
|
1037
|
+
lines.append("- `%s` — %s — withdrawn" % (
|
|
1038
|
+
orig, (meta.get("date") or "undated")))
|
|
1039
|
+
continue
|
|
915
1040
|
sup = kb_unquote(meta.get("supersedes"))
|
|
916
1041
|
lines.append("- `%s` — %s%s — %s" % (
|
|
917
1042
|
orig, (meta.get("date") or "undated"),
|
|
@@ -1056,6 +1181,21 @@ def kb_corpus_check(root):
|
|
|
1056
1181
|
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
|
|
1057
1182
|
orig = given / meta_p.name[:-len(".meta.md")]
|
|
1058
1183
|
rel = "corpus/given/" + orig.name
|
|
1184
|
+
if kb_is_tombstone(meta):
|
|
1185
|
+
# F-077: a withdrawn artifact keeps its record, never its bytes
|
|
1186
|
+
# nor a digest of them -- a digest of the secret-bearing bytes
|
|
1187
|
+
# beside a redacted successor tests password guesses offline.
|
|
1188
|
+
kept = [k for k in ("sha256", "original_sha256")
|
|
1189
|
+
if (meta.get(k) or "").strip()]
|
|
1190
|
+
if kept:
|
|
1191
|
+
errors.append("%s: withdrawn, yet its sidecar keeps %s -- "
|
|
1192
|
+
"a tombstone keeps no digest of the withdrawn "
|
|
1193
|
+
"bytes (distillation.md par.1)"
|
|
1194
|
+
% (rel, ", ".join(k + ":" for k in kept)))
|
|
1195
|
+
if orig.is_file():
|
|
1196
|
+
errors.append("%s: withdrawn, yet its bytes are still here -- "
|
|
1197
|
+
"delete them" % rel)
|
|
1198
|
+
continue
|
|
1059
1199
|
if not orig.is_file():
|
|
1060
1200
|
errors.append("%s: sidecar exists but the original is gone" % rel)
|
|
1061
1201
|
continue
|
|
@@ -1082,17 +1222,36 @@ def kb_corpus_check(root):
|
|
|
1082
1222
|
"extraction is intact, the pointer to the original is "
|
|
1083
1223
|
"not. A warning and not an error: a bundle carries "
|
|
1084
1224
|
"artifacts and sidecars, never the originals, so after "
|
|
1085
|
-
"an import this dangles legitimately"
|
|
1086
|
-
% (rel, op,
|
|
1225
|
+
"an import this dangles legitimately%s"
|
|
1226
|
+
% (rel, _kb_mask_userinfo(op),
|
|
1227
|
+
_kb_mask_userinfo(", ".join(tried)),
|
|
1228
|
+
" -- a non-local identifier belongs in original_ref:, "
|
|
1229
|
+
"which is recorded and never resolved"
|
|
1230
|
+
if re.match(r"^[A-Za-z][A-Za-z0-9+.\-]+://", op) else ""))
|
|
1087
1231
|
sup = kb_unquote(meta.get("supersedes"))
|
|
1088
1232
|
if sup:
|
|
1089
1233
|
superseded.add(sup)
|
|
1090
|
-
|
|
1234
|
+
sup_side = given / (sup + ".meta.md")
|
|
1235
|
+
tomb = sup_side.is_file() and kb_is_tombstone(
|
|
1236
|
+
sdlc_core.load_frontmatter(sdlc_core.read_text(sup_side).splitlines()))
|
|
1237
|
+
if not (given / sup).is_file() and not tomb:
|
|
1091
1238
|
warnings.append("%s: supersedes %r which is not in given/"
|
|
1092
1239
|
% (rel, sup))
|
|
1093
1240
|
kb_check_coverage(rel, orig,
|
|
1094
1241
|
(meta.get("extracted_through") or "").strip(),
|
|
1095
1242
|
_kb_cited_for(cited, orig.name), errors, warnings)
|
|
1243
|
+
# F-077: a file nobody declared -- or bytes a withdrawal left behind. A
|
|
1244
|
+
# stored extraction `<stem>.txt` beside a declared `<stem>.<ext>` is the
|
|
1245
|
+
# declared artifact's own (distillation.md par.1), not a stray.
|
|
1246
|
+
declared_stems = set(Path(m.name[:-len(".meta.md")]).stem
|
|
1247
|
+
for m in given.glob("*.meta.md"))
|
|
1248
|
+
for p in sorted(given.iterdir()):
|
|
1249
|
+
if (p.is_file() and not p.name.endswith(".meta.md")
|
|
1250
|
+
and not (given / (p.name + ".meta.md")).is_file()
|
|
1251
|
+
and not (p.suffix == ".txt" and p.stem in declared_stems)):
|
|
1252
|
+
warnings.append("corpus/given/%s: no sidecar -- an artifact nobody "
|
|
1253
|
+
"declared, or bytes a withdrawal left behind" % p.name)
|
|
1254
|
+
warnings.extend(_kb_same_text_warnings(root))
|
|
1096
1255
|
notes = corpus / "notes"
|
|
1097
1256
|
if notes.is_dir():
|
|
1098
1257
|
for p in sorted(notes.glob("*.md")):
|
|
@@ -1110,6 +1269,226 @@ def kb_corpus_check(root):
|
|
|
1110
1269
|
return errors, warnings
|
|
1111
1270
|
|
|
1112
1271
|
|
|
1272
|
+
# ------------------------------------------------- credentials (F-077)
|
|
1273
|
+
# A credential stored in given/ cannot be fixed in place: the digest check
|
|
1274
|
+
# refuses the edit and git keeps the bytes. These shapes are written from the
|
|
1275
|
+
# formats' own syntax (key=value lines, PEM headers, URL userinfo, bearer
|
|
1276
|
+
# headers); no vendor key format is matched (distillation.md par.1 lists the
|
|
1277
|
+
# declared false positives and false negatives). Findings carry line numbers
|
|
1278
|
+
# and kinds, NEVER the value: the validator's output lands in logs and CI.
|
|
1279
|
+
|
|
1280
|
+
KB_CRED_PAIRS = {("api", "key"): "api-key", ("access", "key"): "access-key",
|
|
1281
|
+
("private", "key"): "private-key",
|
|
1282
|
+
("client", "secret"): "client-secret", ("secret", "key"): "secret"}
|
|
1283
|
+
KB_CRED_LAST = {"password": "password", "passwd": "password",
|
|
1284
|
+
"passphrase": "password", "secret": "secret", "token": "token",
|
|
1285
|
+
"credential": "credentials", "credentials": "credentials",
|
|
1286
|
+
"apikey": "api-key", "accesskey": "access-key"}
|
|
1287
|
+
KB_CRED_ASSIGN_RE = re.compile(
|
|
1288
|
+
r"""^\s*(?:export\s+)?(["']?)([A-Za-z0-9_.\-]+)\1\s*[=:]\s*(.*)$""")
|
|
1289
|
+
KB_CRED_SEGMENT_RE = re.compile(r"[A-Z]?[a-z0-9]+|[A-Z]+(?![a-z])")
|
|
1290
|
+
KB_CRED_PEM_RE = re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")
|
|
1291
|
+
KB_CRED_URL_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.\-]*://[^/\s:@]*:([^@\s/]+)@")
|
|
1292
|
+
KB_CRED_QUERY_RE = re.compile(r"[?&;](?:password|pwd|passwd)=([^&\s;#]+)", re.I)
|
|
1293
|
+
KB_CRED_BEARER_RE = re.compile(r"\bBearer\s+[A-Za-z0-9\-._~+/]{16,}")
|
|
1294
|
+
KB_CRED_PLACEHOLDER_RE = re.compile(
|
|
1295
|
+
r"<.*>|\$\{.*\}|\{\{.*\}\}|%\(.*\)s|ENC\(.*\)|[*xX]+|[|>][-+]?\d*")
|
|
1296
|
+
KB_CRED_LINE_MAX = 4096
|
|
1297
|
+
KB_REVIEWED_RE = re.compile(r"^reviewed\s*[-—–:]\s*\S")
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _kb_cred_real(value):
|
|
1301
|
+
"""True when a value looks like an actual secret, not a placeholder."""
|
|
1302
|
+
v = re.split(r"\s+(?:#|//)", value.strip(), maxsplit=1)[0].strip()
|
|
1303
|
+
if v.endswith(","):
|
|
1304
|
+
v = v[:-1].rstrip()
|
|
1305
|
+
if len(v) >= 2 and v[0] in "\"'" and v[-1] == v[0]:
|
|
1306
|
+
v = v[1:-1]
|
|
1307
|
+
if not v or re.search(r"\s", v):
|
|
1308
|
+
return False
|
|
1309
|
+
if v.lower() in ("true", "false", "null", "none", "yes", "no"):
|
|
1310
|
+
return False
|
|
1311
|
+
if v.startswith("[REDACTED") or KB_CRED_PLACEHOLDER_RE.fullmatch(v):
|
|
1312
|
+
return False
|
|
1313
|
+
return True
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _kb_cred_key_kind(key):
|
|
1317
|
+
segs = [s.lower() for part in re.split(r"[_.\-]", key)
|
|
1318
|
+
for s in KB_CRED_SEGMENT_RE.findall(part)]
|
|
1319
|
+
if len(segs) >= 2 and tuple(segs[-2:]) in KB_CRED_PAIRS:
|
|
1320
|
+
return KB_CRED_PAIRS[tuple(segs[-2:])]
|
|
1321
|
+
return KB_CRED_LAST.get(segs[-1]) if segs else None
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def kb_credential_line_kind(line):
|
|
1325
|
+
"""The credential kind one line carries, or None. Never returns the value."""
|
|
1326
|
+
line = line[:KB_CRED_LINE_MAX]
|
|
1327
|
+
if KB_CRED_PEM_RE.search(line):
|
|
1328
|
+
return "private-key"
|
|
1329
|
+
m = KB_CRED_ASSIGN_RE.match(line)
|
|
1330
|
+
if m:
|
|
1331
|
+
kind = _kb_cred_key_kind(m.group(2))
|
|
1332
|
+
if kind and _kb_cred_real(m.group(3)):
|
|
1333
|
+
return kind
|
|
1334
|
+
m = KB_CRED_URL_RE.search(line)
|
|
1335
|
+
if m and _kb_cred_real(m.group(1)):
|
|
1336
|
+
return "connection-string"
|
|
1337
|
+
m = KB_CRED_QUERY_RE.search(line)
|
|
1338
|
+
if m and _kb_cred_real(m.group(1)):
|
|
1339
|
+
return "connection-string"
|
|
1340
|
+
if KB_CRED_BEARER_RE.search(line):
|
|
1341
|
+
return "bearer-token"
|
|
1342
|
+
return None
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def kb_credential_findings(path):
|
|
1346
|
+
"""[(line_no, kind)] for one file; [] for a binary or unreadable one.
|
|
1347
|
+
|
|
1348
|
+
A NUL in the first 8 KB means binary, unless the file opens with a UTF-16
|
|
1349
|
+
byte-order mark (then it is decoded whole). Text is streamed line by line,
|
|
1350
|
+
each inspected on its first KB_CRED_LINE_MAX characters."""
|
|
1351
|
+
out = []
|
|
1352
|
+
try:
|
|
1353
|
+
with open(path, "rb") as f:
|
|
1354
|
+
head = f.read(8192)
|
|
1355
|
+
if head[:2] in (b"\xff\xfe", b"\xfe\xff"):
|
|
1356
|
+
lines = Path(path).read_bytes().decode("utf-16", errors="replace").splitlines()
|
|
1357
|
+
for n, line in enumerate(lines, 1):
|
|
1358
|
+
kind = kb_credential_line_kind(line)
|
|
1359
|
+
if kind:
|
|
1360
|
+
out.append((n, kind))
|
|
1361
|
+
return out
|
|
1362
|
+
if b"\x00" in head:
|
|
1363
|
+
return []
|
|
1364
|
+
with open(path, encoding="utf-8-sig", errors="replace") as f:
|
|
1365
|
+
for n, line in enumerate(f, 1):
|
|
1366
|
+
kind = kb_credential_line_kind(line.rstrip("\r\n"))
|
|
1367
|
+
if kind:
|
|
1368
|
+
out.append((n, kind))
|
|
1369
|
+
except OSError:
|
|
1370
|
+
return []
|
|
1371
|
+
return out
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _kb_mask_userinfo(text):
|
|
1375
|
+
"""A password inside a URL never reaches the validator's output -- also once
|
|
1376
|
+
a candidate path has turned its `//` into `\\` (Windows join)."""
|
|
1377
|
+
return re.sub(r"([/\\][^/\\\s:@]*:)\S*@", r"\1***@", text)
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def _kb_reviewed(meta):
|
|
1381
|
+
return bool(KB_REVIEWED_RE.match(kb_unquote((meta or {}).get("credential_scan"))))
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _kb_cred_message(rel, found, fix):
|
|
1385
|
+
nums = [str(n) for n, _ in found[:10]] + (["..."] if len(found) > 10 else [])
|
|
1386
|
+
kinds = sorted(set(k for _, k in found))
|
|
1387
|
+
return "%s: %s %s look like a credential (%s) -- %s" % (
|
|
1388
|
+
rel, "line" if len(found) == 1 else "lines", ", ".join(nums),
|
|
1389
|
+
", ".join(kinds), fix)
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def kb_is_tombstone(meta):
|
|
1393
|
+
return bool(kb_unquote((meta or {}).get("withdrawn")))
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def kb_credential_errors(root, docs):
|
|
1397
|
+
"""FS2 errors over given/ artifacts and document-guide snapshots, escapes
|
|
1398
|
+
applied. One carrier per command calls this (check, corpus); validate never."""
|
|
1399
|
+
errors = []
|
|
1400
|
+
given = docs / "corpus" / "given"
|
|
1401
|
+
if given.is_dir():
|
|
1402
|
+
# A stored extraction `<stem>.txt` has no sidecar of its own: it is the
|
|
1403
|
+
# declared `<stem>.<ext>`'s (distillation.md par.1), so its escape lives there.
|
|
1404
|
+
by_stem = {}
|
|
1405
|
+
for m in sorted(given.glob("*.meta.md")):
|
|
1406
|
+
by_stem.setdefault(Path(m.name[:-len(".meta.md")]).stem, m)
|
|
1407
|
+
for p in sorted(given.iterdir()):
|
|
1408
|
+
if not p.is_file() or p.name.endswith(".meta.md"):
|
|
1409
|
+
continue
|
|
1410
|
+
side = given / (p.name + ".meta.md")
|
|
1411
|
+
if not side.is_file() and p.suffix == ".txt" and p.stem in by_stem:
|
|
1412
|
+
side = by_stem[p.stem]
|
|
1413
|
+
meta = (sdlc_core.load_frontmatter(sdlc_core.read_text(side).splitlines())
|
|
1414
|
+
or {}) if side.is_file() else {}
|
|
1415
|
+
if _kb_reviewed(meta):
|
|
1416
|
+
continue
|
|
1417
|
+
found = kb_credential_findings(p)
|
|
1418
|
+
if found:
|
|
1419
|
+
errors.append(_kb_cred_message(
|
|
1420
|
+
"corpus/given/" + p.name, found,
|
|
1421
|
+
"given/ is never edited: redact into a successor and withdraw "
|
|
1422
|
+
"this one (distillation.md par.1), or declare "
|
|
1423
|
+
"`credential_scan: reviewed -- <reason>` in its sidecar"))
|
|
1424
|
+
for rel, _p, meta, _t in sdlc_core.list_guides(root):
|
|
1425
|
+
if (meta.get("source_kind") or "document").strip() != "document":
|
|
1426
|
+
continue
|
|
1427
|
+
df = (meta.get("distilled_from") or "").strip()
|
|
1428
|
+
src = sdlc_core.confine_under(root, df) if df else None
|
|
1429
|
+
if src is None or not src.is_file() or _kb_reviewed(meta):
|
|
1430
|
+
continue
|
|
1431
|
+
try:
|
|
1432
|
+
src.resolve().relative_to(given.resolve())
|
|
1433
|
+
continue # scanned above, once
|
|
1434
|
+
except (OSError, ValueError):
|
|
1435
|
+
pass
|
|
1436
|
+
found = kb_credential_findings(src)
|
|
1437
|
+
if found:
|
|
1438
|
+
errors.append(_kb_cred_message(
|
|
1439
|
+
df, found,
|
|
1440
|
+
"redact into a new snapshot (its name changes) and update %s's "
|
|
1441
|
+
"distilled_from, source_hash and markers, or declare "
|
|
1442
|
+
"`credential_scan: reviewed -- <reason>` in the guide" % rel))
|
|
1443
|
+
return errors
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def _kb_print_credentials(root, docs):
|
|
1447
|
+
errs = kb_credential_errors(root, docs)
|
|
1448
|
+
for e in errs:
|
|
1449
|
+
print("[ERROR] %s" % e)
|
|
1450
|
+
return 1 if errs else 0
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def _kb_same_text_warnings(docs):
|
|
1454
|
+
"""FS6: a .sources/ file and a given/ artifact holding the same text after
|
|
1455
|
+
line-ending normalization. Snapshots are hashed once; a given/ artifact is
|
|
1456
|
+
hashed only when its size is within a factor of two of some snapshot's (CRLF
|
|
1457
|
+
at most doubles a text), so a large extraction corpus is not read for this."""
|
|
1458
|
+
src = docs / "reference" / ".sources"
|
|
1459
|
+
given = docs / "corpus" / "given"
|
|
1460
|
+
if not (src.is_dir() and given.is_dir()):
|
|
1461
|
+
return []
|
|
1462
|
+
snaps = {}
|
|
1463
|
+
sizes = []
|
|
1464
|
+
for p in sorted(src.rglob("*")):
|
|
1465
|
+
if p.is_file():
|
|
1466
|
+
try:
|
|
1467
|
+
snaps.setdefault(sdlc_core.sha256_file(p), []).append(p)
|
|
1468
|
+
sizes.append(p.stat().st_size)
|
|
1469
|
+
except OSError:
|
|
1470
|
+
continue
|
|
1471
|
+
if not snaps:
|
|
1472
|
+
return []
|
|
1473
|
+
lo, hi = min(sizes) // 2, max(sizes) * 2
|
|
1474
|
+
out = []
|
|
1475
|
+
for g in sorted(given.iterdir()):
|
|
1476
|
+
if not g.is_file() or g.name.endswith(".meta.md"):
|
|
1477
|
+
continue
|
|
1478
|
+
try:
|
|
1479
|
+
if not lo <= g.stat().st_size <= hi:
|
|
1480
|
+
continue
|
|
1481
|
+
h = sdlc_core.sha256_file(g)
|
|
1482
|
+
except OSError:
|
|
1483
|
+
continue
|
|
1484
|
+
for s in snaps.get(h, []):
|
|
1485
|
+
out.append("reference/.sources/%s and corpus/given/%s hold the same text "
|
|
1486
|
+
"-- one home per source text: re-point the guide to "
|
|
1487
|
+
"corpus/given/ (portability.md par.6, kb-agentic)"
|
|
1488
|
+
% (s.relative_to(src).as_posix(), g.name))
|
|
1489
|
+
return out
|
|
1490
|
+
|
|
1491
|
+
|
|
1113
1492
|
def kb_sha256_bytes(path):
|
|
1114
1493
|
"""RAW-byte digest for binaries. The spine's sha256_file normalizes CRLF->LF
|
|
1115
1494
|
(right for text snapshots, wrong for binaries, where a hostile 0D0A/0A pair
|
|
@@ -1425,6 +1804,10 @@ def kb_export_closure(docs, slugs):
|
|
|
1425
1804
|
rel[:-len(Path(rel).suffix)] + ".txt" if Path(rel).suffix else rel):
|
|
1426
1805
|
if cand in seen:
|
|
1427
1806
|
continue
|
|
1807
|
+
if cand.endswith(".meta.md") and (docs / cand).is_file() and \
|
|
1808
|
+
kb_is_tombstone(sdlc_core.load_frontmatter(
|
|
1809
|
+
sdlc_core.read_text(docs / cand).splitlines())):
|
|
1810
|
+
continue # F-077: a tombstone never travels
|
|
1428
1811
|
if (docs / cand).is_file():
|
|
1429
1812
|
seen.add(cand)
|
|
1430
1813
|
artifacts.append(cand)
|
|
@@ -1432,10 +1815,15 @@ def kb_export_closure(docs, slugs):
|
|
|
1432
1815
|
|
|
1433
1816
|
|
|
1434
1817
|
def kb_bundle_write(docs, out, selected, artifacts, project):
|
|
1435
|
-
"""Write the bundle. Mirrors the docs-root layout; no path is rewritten.
|
|
1818
|
+
"""Write the bundle. Mirrors the docs-root layout; no path is rewritten.
|
|
1819
|
+
Returns whether a not-yet-honored mark was dropped (F-080) -- a yes/no,
|
|
1820
|
+
never a count: a count of dropped marks is a count of claims by the mark."""
|
|
1436
1821
|
out.mkdir(parents=True, exist_ok=True)
|
|
1437
1822
|
(out / "topics").mkdir(exist_ok=True)
|
|
1823
|
+
dropped = False
|
|
1438
1824
|
for slug, (p, text, _rows) in sorted(selected.items()):
|
|
1825
|
+
text, d = kb_drop_marks(text)
|
|
1826
|
+
dropped = dropped or d
|
|
1439
1827
|
(out / "topics" / p.name).write_text(text, encoding="utf-8")
|
|
1440
1828
|
for rel in artifacts:
|
|
1441
1829
|
dst = out / rel
|
|
@@ -1450,6 +1838,7 @@ def kb_bundle_write(docs, out, selected, artifacts, project):
|
|
|
1450
1838
|
for rel in artifacts:
|
|
1451
1839
|
lines.append("- `%s` sha256:%s" % (rel, kb_sha256_bytes(docs / rel)))
|
|
1452
1840
|
(out / BUNDLE_MANIFEST).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
1841
|
+
return dropped
|
|
1453
1842
|
|
|
1454
1843
|
|
|
1455
1844
|
def kb_import_plan(bundle, docs):
|
|
@@ -1622,15 +2011,19 @@ def kb_refresh_indexes(docs):
|
|
|
1622
2011
|
|
|
1623
2012
|
|
|
1624
2013
|
def kb_validate_surface(docs, full=False):
|
|
1625
|
-
"""Additive seam for marketing: preserve its own validator and report.
|
|
2014
|
+
"""Additive seam for marketing: preserve its own validator and report.
|
|
2015
|
+
`full` is marketing's check: the credential scan (F-077) runs there outside
|
|
2016
|
+
the topics/corpus guard, since document-guide snapshots need no corpus."""
|
|
1626
2017
|
rc = max(memory_validate(docs), _kb_extra_validate(docs))
|
|
2018
|
+
if full:
|
|
2019
|
+
rc = max(rc, _kb_print_credentials(docs.parent, docs))
|
|
1627
2020
|
if full and ((docs / "topics").is_dir() or (docs / "corpus").is_dir()):
|
|
1628
2021
|
cycle = kb_time_cycle(docs)
|
|
1629
2022
|
rc = max(rc, kb_cmd_graph(docs, cycle), kb_cmd_corpus(docs, cycle))
|
|
1630
2023
|
return rc
|
|
1631
2024
|
|
|
1632
2025
|
INTERCEPTED = {"index", "validate", "check", "graph", "corpus", "claim-id",
|
|
1633
|
-
"anchor", "export", "import", "stale", "recall"}
|
|
2026
|
+
"anchor", "export", "import", "stale", "recall", "adopt"}
|
|
1634
2027
|
|
|
1635
2028
|
|
|
1636
2029
|
def _kb_root(args):
|
|
@@ -1670,8 +2063,9 @@ def kb_cmd_graph(docs, cycle=None):
|
|
|
1670
2063
|
return 0
|
|
1671
2064
|
|
|
1672
2065
|
|
|
1673
|
-
def kb_cmd_corpus(docs, cycle=None):
|
|
2066
|
+
def kb_cmd_corpus(docs, cycle=None, extra_errors=()):
|
|
1674
2067
|
errors, warnings = kb_corpus_check(docs)
|
|
2068
|
+
errors = list(extra_errors) + errors
|
|
1675
2069
|
if cycle is None:
|
|
1676
2070
|
cycle = kb_time_cycle(docs)
|
|
1677
2071
|
_tc_cited, tc_chain, tc_dang = kb_time_cycle_warns(cycle)
|
|
@@ -1728,10 +2122,69 @@ def kb_cmd_check(root, docs, strict=False, hybrid=False):
|
|
|
1728
2122
|
("corpus", lambda: kb_cmd_corpus(docs, cycle)))
|
|
1729
2123
|
return sdlc_core.cmd_check(
|
|
1730
2124
|
root, strict=strict, hybrid=hybrid,
|
|
1731
|
-
extra_validate=lambda: max(_kb_extra_validate(docs), memory_validate(docs)
|
|
2125
|
+
extra_validate=lambda: max(_kb_extra_validate(docs), memory_validate(docs),
|
|
2126
|
+
_kb_print_credentials(root, docs)),
|
|
1732
2127
|
extra_sections=sections)
|
|
1733
2128
|
|
|
1734
2129
|
|
|
2130
|
+
def kb_cmd_adopt(root, docs):
|
|
2131
|
+
"""F-077: the dry-run plan of adopting kb-agentic on an existing docs root --
|
|
2132
|
+
which document guides rest on a snapshot, and what moving each into
|
|
2133
|
+
corpus/given/ involves. Reads only; there is no --apply (portability.md
|
|
2134
|
+
par.6: the choice of source and the redaction are judgements)."""
|
|
2135
|
+
if not docs.is_dir():
|
|
2136
|
+
print("[ERROR] no docs root at %s" % docs)
|
|
2137
|
+
return 1
|
|
2138
|
+
given = docs / "corpus" / "given"
|
|
2139
|
+
twins = {}
|
|
2140
|
+
if given.is_dir():
|
|
2141
|
+
for g in sorted(given.iterdir()):
|
|
2142
|
+
if g.is_file() and not g.name.endswith(".meta.md"):
|
|
2143
|
+
try:
|
|
2144
|
+
twins.setdefault(sdlc_core.sha256_file(g), g.name)
|
|
2145
|
+
except OSError:
|
|
2146
|
+
pass
|
|
2147
|
+
print("=== adopt: document guides -> corpus/given/ (dry run) ===")
|
|
2148
|
+
code_n = 0
|
|
2149
|
+
for rel, _p, meta, _t in sdlc_core.list_guides(root):
|
|
2150
|
+
if (meta.get("source_kind") or "document").strip() != "document":
|
|
2151
|
+
code_n += 1
|
|
2152
|
+
continue
|
|
2153
|
+
df = (meta.get("distilled_from") or "").strip()
|
|
2154
|
+
print("%s" % rel)
|
|
2155
|
+
print(" distilled_from: %s" % (df or "(none)"))
|
|
2156
|
+
src = sdlc_core.confine_under(root, df) if df else None
|
|
2157
|
+
if df and src is None:
|
|
2158
|
+
print(" source: outside the project root -- not read")
|
|
2159
|
+
continue
|
|
2160
|
+
if src is None or not src.is_file():
|
|
2161
|
+
print(" source: missing")
|
|
2162
|
+
continue
|
|
2163
|
+
try:
|
|
2164
|
+
where = "/" + src.relative_to(root.resolve()).as_posix()
|
|
2165
|
+
except ValueError:
|
|
2166
|
+
where = ""
|
|
2167
|
+
if "/corpus/given/" in where:
|
|
2168
|
+
print(" source: already in corpus/given/")
|
|
2169
|
+
continue
|
|
2170
|
+
print(" source: %s" % (".sources/ snapshot" if "/reference/.sources/" in where
|
|
2171
|
+
else "elsewhere"))
|
|
2172
|
+
try:
|
|
2173
|
+
twin = twins.get(sdlc_core.sha256_file(src))
|
|
2174
|
+
except OSError:
|
|
2175
|
+
twin = None
|
|
2176
|
+
if twin:
|
|
2177
|
+
print(" same text already in corpus/given/%s" % twin)
|
|
2178
|
+
found = kb_credential_findings(src)
|
|
2179
|
+
if found:
|
|
2180
|
+
print(" " + _kb_cred_message("credential-shaped", found,
|
|
2181
|
+
"redact before storing (distillation.md par.1)")
|
|
2182
|
+
.split(": ", 1)[1])
|
|
2183
|
+
print("%d code guide(s) stay on .sources/." % code_n)
|
|
2184
|
+
print("Nothing was written. Procedure: portability.md par.6 (kb-agentic).")
|
|
2185
|
+
return 0
|
|
2186
|
+
|
|
2187
|
+
|
|
1735
2188
|
def kb_cmd_stale(root, docs, hybrid=False):
|
|
1736
2189
|
"""F-044: the consolidation view. The spine's output and exit semantics
|
|
1737
2190
|
come first (--hybrid passed through untouched), then the `## claims`
|
|
@@ -1831,9 +2284,12 @@ def kb_cmd_export(args):
|
|
|
1831
2284
|
if errors:
|
|
1832
2285
|
return 1
|
|
1833
2286
|
out = Path(args.out)
|
|
1834
|
-
kb_bundle_write(docs, out, selected, artifacts, root.name)
|
|
2287
|
+
dropped = kb_bundle_write(docs, out, selected, artifacts, root.name)
|
|
1835
2288
|
print("[ok] bundle written: %s" % out)
|
|
1836
2289
|
print(" topics: %d, artifacts: %d" % (len(selected), len(artifacts)))
|
|
2290
|
+
if dropped:
|
|
2291
|
+
print(" not-yet-honored markers dropped: they describe this "
|
|
2292
|
+
"project's code")
|
|
1837
2293
|
if added:
|
|
1838
2294
|
# never silent: a set that grew is a fact about the export, and the
|
|
1839
2295
|
# alternative -- dropping the partner rows -- ships a broken tree.
|
|
@@ -1886,11 +2342,50 @@ def kb_cmd_orient(argv):
|
|
|
1886
2342
|
needs to locate topics/, and ignores everything else.
|
|
1887
2343
|
|
|
1888
2344
|
The append fails open: a broken graph read must never break orient itself.
|
|
2345
|
+
It is also bounded (F-075): the spine leaves ORIENT_APPEND_RESERVE of the
|
|
2346
|
+
client's hook cap free, and the append is cut to it -- the topic router
|
|
2347
|
+
first, since it is the part that grows -- so the whole output stays under
|
|
2348
|
+
the cap.
|
|
1889
2349
|
"""
|
|
1890
2350
|
try:
|
|
1891
2351
|
rc = sdlc_core.main(argv)
|
|
1892
2352
|
except SystemExit as e:
|
|
1893
2353
|
rc = e.code if isinstance(e.code, int) else 1
|
|
2354
|
+
buf = io.StringIO()
|
|
2355
|
+
try:
|
|
2356
|
+
with contextlib.redirect_stdout(buf):
|
|
2357
|
+
_kb_orient_append(argv)
|
|
2358
|
+
except (Exception, SystemExit):
|
|
2359
|
+
pass
|
|
2360
|
+
text = buf.getvalue()
|
|
2361
|
+
if text:
|
|
2362
|
+
print(_fit_orient_append(text, sdlc_core.ORIENT_APPEND_RESERVE), end="")
|
|
2363
|
+
return rc
|
|
2364
|
+
|
|
2365
|
+
|
|
2366
|
+
def _fit_orient_append(text, budget):
|
|
2367
|
+
"""`text` within `budget` characters: shrink the topic-router block first,
|
|
2368
|
+
then, if the rest alone is too wide, cut the whole append. Never raises."""
|
|
2369
|
+
if len(text) <= budget:
|
|
2370
|
+
return text
|
|
2371
|
+
lines = text.rstrip("\n").split("\n")
|
|
2372
|
+
try:
|
|
2373
|
+
i = lines.index("## Topic router")
|
|
2374
|
+
j = next((k for k in range(i + 1, len(lines)) if not lines[k].strip()), len(lines))
|
|
2375
|
+
block, others = lines[i + 1:j], lines[:i + 1] + lines[j:]
|
|
2376
|
+
room = budget - len("\n".join(others)) - 2
|
|
2377
|
+
if room > 0:
|
|
2378
|
+
fitted = sdlc_core.orient_fit(block, room, "topics/INDEX.md")
|
|
2379
|
+
out = "\n".join(lines[:i + 1] + fitted + lines[j:]) + "\n"
|
|
2380
|
+
if len(out) <= budget:
|
|
2381
|
+
return out
|
|
2382
|
+
except ValueError:
|
|
2383
|
+
pass
|
|
2384
|
+
return "\n".join(sdlc_core.orient_fit(lines, budget - 1, "the knowledge layer's files")) + "\n"
|
|
2385
|
+
|
|
2386
|
+
|
|
2387
|
+
def _kb_orient_append(argv):
|
|
2388
|
+
"""The lines kb_cmd_orient appends after the spine's orientation."""
|
|
1894
2389
|
# Pre-bind so a throw INSIDE the router print (after root was validly
|
|
1895
2390
|
# bound) does not erase it -- the recency block below must survive a
|
|
1896
2391
|
# router failure with the bound root intact (closure R2 finding).
|
|
@@ -1944,7 +2439,7 @@ def kb_cmd_orient(argv):
|
|
|
1944
2439
|
# skill's own worktree hygiene makes that routine), which is why the
|
|
1945
2440
|
# frontmatter date is primary.
|
|
1946
2441
|
if root is None:
|
|
1947
|
-
return
|
|
2442
|
+
return
|
|
1948
2443
|
notes_dir = root / name / "corpus" / "notes"
|
|
1949
2444
|
if notes_dir.is_dir():
|
|
1950
2445
|
import datetime as _dt
|
|
@@ -2017,7 +2512,6 @@ def kb_cmd_orient(argv):
|
|
|
2017
2512
|
% (n_docs, m_exp, k_chain))
|
|
2018
2513
|
except (Exception, SystemExit):
|
|
2019
2514
|
pass
|
|
2020
|
-
return rc
|
|
2021
2515
|
|
|
2022
2516
|
|
|
2023
2517
|
def kb_cmd_help():
|
|
@@ -2044,6 +2538,9 @@ shared project memory -- also available:
|
|
|
2044
2538
|
anchor <path> <phrase> resolve a phrase to a verified locator span
|
|
2045
2539
|
export --out <dir> bundle a subgraph WITH the bytes its claims cite
|
|
2046
2540
|
import <dir> merge a bundle in additively (never overwrites)
|
|
2541
|
+
adopt dry run: document guides resting on .sources/
|
|
2542
|
+
snapshots, and what moving them into corpus/given/
|
|
2543
|
+
involves (writes nothing; portability.md par.6)
|
|
2047
2544
|
remind one-line per-turn reminder (UserPromptSubmit hook,
|
|
2048
2545
|
opt-in -- ENFORCEMENT par.4; constant, zero-read)
|
|
2049
2546
|
|
|
@@ -2111,6 +2608,9 @@ def main(argv=None):
|
|
|
2111
2608
|
p.add_argument("--dry-run", action="store_true")
|
|
2112
2609
|
p.add_argument("--root")
|
|
2113
2610
|
p.add_argument("--docs-dir")
|
|
2611
|
+
p = sub.add_parser("adopt")
|
|
2612
|
+
p.add_argument("--root")
|
|
2613
|
+
p.add_argument("--docs-dir")
|
|
2114
2614
|
args = ap.parse_args(argv)
|
|
2115
2615
|
if args.cmd == "claim-id":
|
|
2116
2616
|
return kb_cmd_claim_id(args)
|
|
@@ -2135,7 +2635,9 @@ def main(argv=None):
|
|
|
2135
2635
|
if args.cmd == "graph":
|
|
2136
2636
|
return kb_cmd_graph(docs)
|
|
2137
2637
|
if args.cmd == "corpus":
|
|
2138
|
-
return kb_cmd_corpus(docs)
|
|
2638
|
+
return kb_cmd_corpus(docs, extra_errors=kb_credential_errors(root, docs))
|
|
2639
|
+
if args.cmd == "adopt":
|
|
2640
|
+
return kb_cmd_adopt(root, docs)
|
|
2139
2641
|
if args.cmd == "validate":
|
|
2140
2642
|
return kb_cmd_validate(root, docs, strict=args.strict, hybrid=args.hybrid)
|
|
2141
2643
|
return kb_cmd_check(root, docs, strict=args.strict, hybrid=args.hybrid)
|