@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.
@@ -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).
@@ -125,6 +143,21 @@ def kb_unquote(value):
125
143
  return v
126
144
 
127
145
 
146
+ def frontmatter_date(meta):
147
+ """A note's or a sidecar's `date:` as `YYYY-MM-DD`, or None (F-073).
148
+ Quotes stripped, a datetime suffix tolerated, an impossible date refused --
149
+ the one parser behind orient's recency probe and any chronology reader."""
150
+ m = re.match(r"(\d{4}-\d{2}-\d{2})", kb_unquote((meta or {}).get("date")))
151
+ if not m:
152
+ return None
153
+ try:
154
+ import datetime as _dt
155
+ _dt.date.fromisoformat(m.group(1))
156
+ except ValueError:
157
+ return None
158
+ return m.group(1)
159
+
160
+
128
161
  def kb_parse_scope(text):
129
162
  """'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
130
163
  ('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
@@ -336,7 +369,19 @@ def kb_check_claims(root):
336
369
  if rel not in fm_cache:
337
370
  fm_cache[rel] = _note_frontmatter(root, rel)
338
371
  return fm_cache[rel]
372
+ # F-069: the same per-run memo for the source paths themselves -- each
373
+ # distinct path is resolved and stat'ed once, and each cited extraction
374
+ # read once (loc_cache, owned here, dropped when this call returns).
375
+ src_cache = {}
376
+ loc_cache = {}
377
+
378
+ def _resolved(path_s):
379
+ if path_s not in src_cache:
380
+ t = sdlc_core.confine_under(root, path_s)
381
+ src_cache[path_s] = (t, t is not None and t.is_file())
382
+ return src_cache[path_s]
339
383
  per_file_rows = []
384
+ marked = [] # (where, row, [ANALYSIS_ targets]) -- F-080
340
385
  for p in sorted(topics.glob("*.md")):
341
386
  rel = "topics/" + p.name
342
387
  text = sdlc_core.read_text(p)
@@ -357,16 +402,16 @@ def kb_check_claims(root):
357
402
  errors.append("%s: source %r has no locator" % (where, src))
358
403
  continue
359
404
  path_s, loc = src.rsplit("#", 1)
360
- target = sdlc_core.confine_under(root, path_s)
405
+ target, is_file = _resolved(path_s)
361
406
  if target is None:
362
407
  errors.append("%s: source %r escapes the docs root" % (where, src))
363
408
  continue
364
- if not target.is_file():
409
+ if not is_file:
365
410
  errors.append("%s: source %r does not resolve — a claim whose "
366
411
  "origin cannot be reopened is model knowledge"
367
412
  % (where, src))
368
413
  continue
369
- kb_check_locator(target, loc, where, errors)
414
+ kb_check_locator(target, loc, where, errors, loc_cache)
370
415
  # --- provenance ---
371
416
  prov = row["prov"]
372
417
  # Resolved ONCE, for every provenance: the non-GIVEN classes read it
@@ -419,11 +464,22 @@ def kb_check_claims(root):
419
464
  except ValueError as e:
420
465
  errors.append("%s: %s" % (where, e))
421
466
  qk = None
422
- if row["about"] not in ("", "-"):
423
- m = re.match(r"^([a-z0-9-]+) -> ([a-z0-9-]+)$", row["about"])
424
- if not m:
425
- errors.append("%s: about must be '<predicate> -> <slug>' or '-'"
426
- % where)
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))
427
483
  # --- atomicity hint (F-047): a row atomic as provenance can be
428
484
  # plural as meaning. Text cannot PROVE plurality, so this is a
429
485
  # note — never a warning or an error, never the exit code.
@@ -553,11 +609,108 @@ def kb_check_claims(root):
553
609
  "name %s back; one flipped cell must never "
554
610
  "silently end a disagreement"
555
611
  % (where, t, row["id"]))
612
+ kb_check_marks(root, marked, per_file_rows, errors)
556
613
  return errors, warnings, notes
557
614
 
558
615
 
559
- def kb_check_locator(target, loc, where, errors):
560
- """A locator must address an existing span of the stored bytes."""
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
+
706
+ def kb_check_locator(target, loc, where, errors, cache=None):
707
+ """A locator must address an existing span of the stored bytes.
708
+
709
+ `cache` is an optional dict the CALLER owns for one run (F-069): keyed by
710
+ the path actually read, it holds only what the checks compare against --
711
+ page lengths, a line count, or False for a missing extraction -- never the
712
+ text. Findings are still appended per call, so per row. Without it, every
713
+ call reads the file, as before."""
561
714
  m = LOC_PAGE_RE.match(loc)
562
715
  if m:
563
716
  page, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
@@ -566,17 +719,24 @@ def kb_check_locator(target, loc, where, errors):
566
719
  return
567
720
  # offsets address the stored extraction (<stem>.txt beside the original)
568
721
  ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
569
- if not ext.is_file():
722
+ key = ("p", ext)
723
+ if cache is None or key not in cache:
724
+ lengths = ([len(pg) for pg in sdlc_core.read_text(ext).split("\f")]
725
+ if ext.is_file() else False)
726
+ if cache is not None:
727
+ cache[key] = lengths
728
+ else:
729
+ lengths = cache[key]
730
+ if lengths is False:
570
731
  errors.append("%s: no stored extraction %s for locator %r — offsets "
571
732
  "must address kept bytes" % (where, ext.name, loc))
572
733
  return
573
- pages = sdlc_core.read_text(ext).split("\f")
574
- if page < 1 or page > len(pages):
734
+ if page < 1 or page > len(lengths):
575
735
  errors.append("%s: locator %r addresses page %d of %d" %
576
- (where, loc, page, len(pages)))
577
- elif b > len(pages[page - 1]):
736
+ (where, loc, page, len(lengths)))
737
+ elif b > lengths[page - 1]:
578
738
  errors.append("%s: locator %r spans past the end of page %d "
579
- "(%d chars)" % (where, loc, page, len(pages[page - 1])))
739
+ "(%d chars)" % (where, loc, page, lengths[page - 1]))
580
740
  return
581
741
  m = LOC_LINE_RE.match(loc)
582
742
  if m:
@@ -584,7 +744,13 @@ def kb_check_locator(target, loc, where, errors):
584
744
  if a > b or a < 1:
585
745
  errors.append("%s: locator %r has an invalid line range" % (where, loc))
586
746
  return
587
- n = sdlc_core.read_text(target).count("\n") + 1
747
+ key = ("L", target)
748
+ if cache is None or key not in cache:
749
+ n = sdlc_core.read_text(target).count("\n") + 1
750
+ if cache is not None:
751
+ cache[key] = n
752
+ else:
753
+ n = cache[key]
588
754
  if b > n:
589
755
  errors.append("%s: locator %r spans past line %d" % (where, loc, n))
590
756
  return
@@ -867,6 +1033,10 @@ def kb_build_corpus_index(root):
867
1033
  for meta_p in sorted(given.glob("*.meta.md")):
868
1034
  meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
869
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
870
1040
  sup = kb_unquote(meta.get("supersedes"))
871
1041
  lines.append("- `%s` — %s%s — %s" % (
872
1042
  orig, (meta.get("date") or "undated"),
@@ -1011,6 +1181,21 @@ def kb_corpus_check(root):
1011
1181
  meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
1012
1182
  orig = given / meta_p.name[:-len(".meta.md")]
1013
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
1014
1199
  if not orig.is_file():
1015
1200
  errors.append("%s: sidecar exists but the original is gone" % rel)
1016
1201
  continue
@@ -1037,17 +1222,36 @@ def kb_corpus_check(root):
1037
1222
  "extraction is intact, the pointer to the original is "
1038
1223
  "not. A warning and not an error: a bundle carries "
1039
1224
  "artifacts and sidecars, never the originals, so after "
1040
- "an import this dangles legitimately"
1041
- % (rel, op, ", ".join(tried)))
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 ""))
1042
1231
  sup = kb_unquote(meta.get("supersedes"))
1043
1232
  if sup:
1044
1233
  superseded.add(sup)
1045
- if not (given / sup).is_file():
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:
1046
1238
  warnings.append("%s: supersedes %r which is not in given/"
1047
1239
  % (rel, sup))
1048
1240
  kb_check_coverage(rel, orig,
1049
1241
  (meta.get("extracted_through") or "").strip(),
1050
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))
1051
1255
  notes = corpus / "notes"
1052
1256
  if notes.is_dir():
1053
1257
  for p in sorted(notes.glob("*.md")):
@@ -1065,6 +1269,226 @@ def kb_corpus_check(root):
1065
1269
  return errors, warnings
1066
1270
 
1067
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
+
1068
1492
  def kb_sha256_bytes(path):
1069
1493
  """RAW-byte digest for binaries. The spine's sha256_file normalizes CRLF->LF
1070
1494
  (right for text snapshots, wrong for binaries, where a hostile 0D0A/0A pair
@@ -1085,12 +1509,18 @@ KB_GENERATED_RE = re.compile(r"GENERATED by \S+ index - do not edit by hand")
1085
1509
  KB_HEX12_RE = re.compile(r"^[0-9a-f]{12}$")
1086
1510
 
1087
1511
 
1088
- def _kb_hex_bounded(text, cid):
1089
- """True when cid appears NOT embedded in a longer hex run: a git hash
1090
- sharing a substring must never count as a citation (either side, either
1091
- case). cid is pre-validated 12-hex, so it is regex-safe by construction."""
1092
- return re.search(r"(?<![0-9a-fA-F])%s(?![0-9a-fA-F])" % cid,
1093
- text) is not None
1512
+ # A citation of a claim id is a run of exactly its 12 lowercase hex characters
1513
+ # NOT embedded in a longer hex run: a git hash sharing a substring must never
1514
+ # count as a citation (either side, either case). Two such bounded runs cannot
1515
+ # overlap, so findall returns every one of them.
1516
+ KB_HEX_TOKEN_RE = re.compile(r"(?<![0-9a-fA-F])[0-9a-f]{12}(?![0-9a-fA-F])")
1517
+
1518
+
1519
+ def _kb_hex_tokens(text):
1520
+ """The ids a text cites, extracted in one pass (F-069). Callers test
1521
+ membership instead of scanning the text once per id: that per-(document,
1522
+ id) scan made the time cycle grow with superseded ids x living documents."""
1523
+ return frozenset(KB_HEX_TOKEN_RE.findall(text))
1094
1524
 
1095
1525
 
1096
1526
  def _kb_df_entries(value):
@@ -1172,19 +1602,23 @@ def kb_time_cycle(docs):
1172
1602
  if (meta.get("status") or "").strip() == "SUPERSEDED":
1173
1603
  continue
1174
1604
  living.append((rel, text))
1175
- for rel, text in living:
1176
- for old in sorted(superseded):
1177
- if _kb_hex_bounded(text, old):
1178
- succs = superseded[old]
1179
- missing = tuple(x for x in succs
1180
- if not _kb_hex_bounded(text, x))
1181
- if missing:
1182
- cited.append((rel, old, missing, succs))
1605
+ # One token pass per living document, and none at all on a KB with no
1606
+ # supersession and no validity window (the orient line runs this at every
1607
+ # session start).
1608
+ tokens = ({rel: _kb_hex_tokens(text) for rel, text in living}
1609
+ if superseded or windows else {})
1610
+ for rel, _text in living:
1611
+ cites = tokens[rel] if tokens else frozenset()
1612
+ for old in cites.intersection(superseded):
1613
+ succs = superseded[old]
1614
+ missing = tuple(x for x in succs if x not in cites)
1615
+ if missing:
1616
+ cited.append((rel, old, missing, succs))
1183
1617
  today = _dt.date.today().isoformat()
1184
1618
  for topic_rel, rid, until in sorted(windows):
1185
1619
  if until <= today:
1186
- by = tuple(rel for rel, text in living
1187
- if rel != topic_rel and _kb_hex_bounded(text, rid))
1620
+ by = tuple(rel for rel, _text in living
1621
+ if rel != topic_rel and rid in tokens[rel])
1188
1622
  expired.append((topic_rel, rid, until, by))
1189
1623
  rev = {}
1190
1624
  node_meta = {}
@@ -1370,6 +1804,10 @@ def kb_export_closure(docs, slugs):
1370
1804
  rel[:-len(Path(rel).suffix)] + ".txt" if Path(rel).suffix else rel):
1371
1805
  if cand in seen:
1372
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
1373
1811
  if (docs / cand).is_file():
1374
1812
  seen.add(cand)
1375
1813
  artifacts.append(cand)
@@ -1377,10 +1815,15 @@ def kb_export_closure(docs, slugs):
1377
1815
 
1378
1816
 
1379
1817
  def kb_bundle_write(docs, out, selected, artifacts, project):
1380
- """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."""
1381
1821
  out.mkdir(parents=True, exist_ok=True)
1382
1822
  (out / "topics").mkdir(exist_ok=True)
1823
+ dropped = False
1383
1824
  for slug, (p, text, _rows) in sorted(selected.items()):
1825
+ text, d = kb_drop_marks(text)
1826
+ dropped = dropped or d
1384
1827
  (out / "topics" / p.name).write_text(text, encoding="utf-8")
1385
1828
  for rel in artifacts:
1386
1829
  dst = out / rel
@@ -1395,6 +1838,7 @@ def kb_bundle_write(docs, out, selected, artifacts, project):
1395
1838
  for rel in artifacts:
1396
1839
  lines.append("- `%s` sha256:%s" % (rel, kb_sha256_bytes(docs / rel)))
1397
1840
  (out / BUNDLE_MANIFEST).write_text("\n".join(lines) + "\n", encoding="utf-8")
1841
+ return dropped
1398
1842
 
1399
1843
 
1400
1844
  def kb_import_plan(bundle, docs):
@@ -1491,16 +1935,17 @@ def memory_records(docs):
1491
1935
  paths.append(p)
1492
1936
  records = []
1493
1937
  for p in sorted(set(paths), key=lambda p: p.relative_to(docs).as_posix()):
1494
- raw = p.read_bytes()
1495
- text = raw.decode("utf-8", errors="replace")
1938
+ text = p.read_bytes().decode("utf-8", errors="replace")
1496
1939
  meta = sdlc_core.load_frontmatter(text.splitlines())
1497
1940
  title = next((line[2:].strip() for line in text.splitlines()
1498
1941
  if line.startswith("# ")), p.stem)
1942
+ # LF-normalized like the guides' source_hash: an autocrlf checkout must
1943
+ # regenerate the same catalog as an LF one.
1499
1944
  records.append((p.relative_to(docs).as_posix(),
1500
1945
  kb_unquote(meta.get("domain")) or default,
1501
1946
  ", ".join(_as_list(meta.get("topics", ""))),
1502
1947
  kb_unquote(meta.get("description")) or title,
1503
- hashlib.sha256(raw).hexdigest()))
1948
+ sdlc_core.sha256_file(p)))
1504
1949
  return records
1505
1950
 
1506
1951
 
@@ -1566,15 +2011,19 @@ def kb_refresh_indexes(docs):
1566
2011
 
1567
2012
 
1568
2013
  def kb_validate_surface(docs, full=False):
1569
- """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."""
1570
2017
  rc = max(memory_validate(docs), _kb_extra_validate(docs))
2018
+ if full:
2019
+ rc = max(rc, _kb_print_credentials(docs.parent, docs))
1571
2020
  if full and ((docs / "topics").is_dir() or (docs / "corpus").is_dir()):
1572
2021
  cycle = kb_time_cycle(docs)
1573
2022
  rc = max(rc, kb_cmd_graph(docs, cycle), kb_cmd_corpus(docs, cycle))
1574
2023
  return rc
1575
2024
 
1576
2025
  INTERCEPTED = {"index", "validate", "check", "graph", "corpus", "claim-id",
1577
- "anchor", "export", "import", "stale", "recall"}
2026
+ "anchor", "export", "import", "stale", "recall", "adopt"}
1578
2027
 
1579
2028
 
1580
2029
  def _kb_root(args):
@@ -1614,8 +2063,9 @@ def kb_cmd_graph(docs, cycle=None):
1614
2063
  return 0
1615
2064
 
1616
2065
 
1617
- def kb_cmd_corpus(docs, cycle=None):
2066
+ def kb_cmd_corpus(docs, cycle=None, extra_errors=()):
1618
2067
  errors, warnings = kb_corpus_check(docs)
2068
+ errors = list(extra_errors) + errors
1619
2069
  if cycle is None:
1620
2070
  cycle = kb_time_cycle(docs)
1621
2071
  _tc_cited, tc_chain, tc_dang = kb_time_cycle_warns(cycle)
@@ -1662,17 +2112,77 @@ def kb_cmd_validate(root, docs, strict=False, hybrid=False):
1662
2112
 
1663
2113
  def kb_cmd_check(root, docs, strict=False, hybrid=False):
1664
2114
  # The spine's check owns its banners and summary line: reuse it whole, so a
1665
- # tree with no kb surface gets byte-identical output. The kb checks run
1666
- # after, and only when the surface exists.
1667
- rc = sdlc_core.cmd_check(root, strict=strict, hybrid=hybrid)
1668
- rc = max(rc, _kb_extra_validate(docs), memory_validate(docs))
2115
+ # tree with no kb surface gets byte-identical output. The kb checks ride its
2116
+ # hooks -- index freshness in the validate stage, graph/corpus as sections
2117
+ # only when the surface exists -- so the summary is printed after all of them.
2118
+ sections = ()
1669
2119
  if (docs / "topics").is_dir() or (docs / "corpus").is_dir():
1670
2120
  cycle = kb_time_cycle(docs)
1671
- print("===== graph =====")
1672
- rc = max(rc, kb_cmd_graph(docs, cycle))
1673
- print("===== corpus =====")
1674
- rc = max(rc, kb_cmd_corpus(docs, cycle))
1675
- return rc
2121
+ sections = (("graph", lambda: kb_cmd_graph(docs, cycle)),
2122
+ ("corpus", lambda: kb_cmd_corpus(docs, cycle)))
2123
+ return sdlc_core.cmd_check(
2124
+ root, strict=strict, hybrid=hybrid,
2125
+ extra_validate=lambda: max(_kb_extra_validate(docs), memory_validate(docs),
2126
+ _kb_print_credentials(root, docs)),
2127
+ extra_sections=sections)
2128
+
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
1676
2186
 
1677
2187
 
1678
2188
  def kb_cmd_stale(root, docs, hybrid=False):
@@ -1774,9 +2284,12 @@ def kb_cmd_export(args):
1774
2284
  if errors:
1775
2285
  return 1
1776
2286
  out = Path(args.out)
1777
- kb_bundle_write(docs, out, selected, artifacts, root.name)
2287
+ dropped = kb_bundle_write(docs, out, selected, artifacts, root.name)
1778
2288
  print("[ok] bundle written: %s" % out)
1779
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")
1780
2293
  if added:
1781
2294
  # never silent: a set that grew is a fact about the export, and the
1782
2295
  # alternative -- dropping the partner rows -- ships a broken tree.
@@ -1829,11 +2342,50 @@ def kb_cmd_orient(argv):
1829
2342
  needs to locate topics/, and ignores everything else.
1830
2343
 
1831
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.
1832
2349
  """
1833
2350
  try:
1834
2351
  rc = sdlc_core.main(argv)
1835
2352
  except SystemExit as e:
1836
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."""
1837
2389
  # Pre-bind so a throw INSIDE the router print (after root was validly
1838
2390
  # bound) does not erase it -- the recency block below must survive a
1839
2391
  # router failure with the bound root intact (closure R2 finding).
@@ -1887,7 +2439,7 @@ def kb_cmd_orient(argv):
1887
2439
  # skill's own worktree hygiene makes that routine), which is why the
1888
2440
  # frontmatter date is primary.
1889
2441
  if root is None:
1890
- return rc
2442
+ return
1891
2443
  notes_dir = root / name / "corpus" / "notes"
1892
2444
  if notes_dir.is_dir():
1893
2445
  import datetime as _dt
@@ -1903,14 +2455,13 @@ def kb_cmd_orient(argv):
1903
2455
  # it -- the file was already fully read, so the cap bought
1904
2456
  # only failure. load_frontmatter is the authoritative
1905
2457
  # reader (fence-bounded, so a column-0 date: in the body
1906
- # still cannot misdate the note); kb_unquote covers quoted
1907
- # dates; the prefix match tolerates a datetime suffix.
2458
+ # still cannot misdate the note); frontmatter_date covers
2459
+ # quoted dates and tolerates a datetime suffix.
1908
2460
  meta = sdlc_core.load_frontmatter(
1909
2461
  sdlc_core.read_text(p).splitlines()) or {}
1910
- m = re.match(r"(\d{4}-\d{2}-\d{2})",
1911
- kb_unquote(meta.get("date")))
1912
- if m:
1913
- stamp = _dt.date.fromisoformat(m.group(1))
2462
+ day = frontmatter_date(meta)
2463
+ if day:
2464
+ stamp = _dt.date.fromisoformat(day)
1914
2465
  except Exception:
1915
2466
  pass
1916
2467
  if stamp is None:
@@ -1961,7 +2512,6 @@ def kb_cmd_orient(argv):
1961
2512
  % (n_docs, m_exp, k_chain))
1962
2513
  except (Exception, SystemExit):
1963
2514
  pass
1964
- return rc
1965
2515
 
1966
2516
 
1967
2517
  def kb_cmd_help():
@@ -1988,6 +2538,9 @@ shared project memory -- also available:
1988
2538
  anchor <path> <phrase> resolve a phrase to a verified locator span
1989
2539
  export --out <dir> bundle a subgraph WITH the bytes its claims cite
1990
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)
1991
2544
  remind one-line per-turn reminder (UserPromptSubmit hook,
1992
2545
  opt-in -- ENFORCEMENT par.4; constant, zero-read)
1993
2546
 
@@ -2055,6 +2608,9 @@ def main(argv=None):
2055
2608
  p.add_argument("--dry-run", action="store_true")
2056
2609
  p.add_argument("--root")
2057
2610
  p.add_argument("--docs-dir")
2611
+ p = sub.add_parser("adopt")
2612
+ p.add_argument("--root")
2613
+ p.add_argument("--docs-dir")
2058
2614
  args = ap.parse_args(argv)
2059
2615
  if args.cmd == "claim-id":
2060
2616
  return kb_cmd_claim_id(args)
@@ -2079,7 +2635,9 @@ def main(argv=None):
2079
2635
  if args.cmd == "graph":
2080
2636
  return kb_cmd_graph(docs)
2081
2637
  if args.cmd == "corpus":
2082
- 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)
2083
2641
  if args.cmd == "validate":
2084
2642
  return kb_cmd_validate(root, docs, strict=args.strict, hybrid=args.hybrid)
2085
2643
  return kb_cmd_check(root, docs, strict=args.strict, hybrid=args.hybrid)