@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.
- package/CHANGELOG.md +102 -0
- package/README.md +4 -2
- package/gemini-extension.json +1 -1
- package/package.json +7 -2
- package/scripts/init.js +62 -62
- package/scripts/lib.js +98 -98
- package/scripts/postinstall.js +16 -16
- package/scripts/preuninstall.js +12 -12
- package/skills/course-creator/ENFORCEMENT.md +17 -5
- package/skills/course-creator/SKILL.md +8 -4
- package/skills/course-creator/dispatch.md +5 -5
- package/skills/course-creator/docs_site/app.js +1133 -0
- package/skills/course-creator/docs_site/index.html +17 -0
- package/skills/course-creator/docs_site/strings.js +323 -0
- package/skills/course-creator/docs_site/style.css +288 -0
- package/skills/course-creator/elicitation.md +23 -8
- package/skills/course-creator/guides.md +7 -1
- package/skills/course-creator/memory.md +1 -1
- package/skills/course-creator/review.md +107 -13
- package/skills/course-creator/routing.md +4 -0
- package/skills/course-creator/scripts/course_check.py +132 -132
- package/skills/course-creator/scripts/docs_site.py +1448 -0
- package/skills/course-creator/scripts/knowledge.py +620 -62
- package/skills/course-creator/scripts/sdlc_core.py +958 -84
- package/skills/course-creator/scripts/shared_files.py +10 -0
- package/skills/course-creator/scripts/shared_manifest.json +23 -14
- package/skills/course-creator/templates.md +51 -8
- package/skills/course-creator/vision.md +308 -245
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
560
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
577
|
-
elif b >
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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,
|
|
1187
|
-
if rel != topic_rel and
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1666
|
-
#
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
|
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);
|
|
1907
|
-
# dates
|
|
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
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
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)
|