@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.
@@ -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
- if row["about"] not in ("", "-"):
449
- m = re.match(r"^([a-z0-9-]+) -> ([a-z0-9-]+)$", row["about"])
450
- if not m:
451
- errors.append("%s: about must be '<predicate> -> <slug>' or '-'"
452
- % 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))
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, ", ".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 ""))
1087
1231
  sup = kb_unquote(meta.get("supersedes"))
1088
1232
  if sup:
1089
1233
  superseded.add(sup)
1090
- 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:
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 rc
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)