@antoneeo/kb-agentic-skill 1.4.7 → 1.5.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.
@@ -31,6 +31,8 @@ Pure stdlib. ASCII output only (Windows-console safe).
31
31
  """
32
32
  import argparse
33
33
  import hashlib
34
+ import ntpath
35
+ import posixpath
34
36
  import re
35
37
  import sys
36
38
  from pathlib import Path
@@ -272,10 +274,94 @@ def kb_fill_ids(text):
272
274
 
273
275
 
274
276
  def _note_frontmatter(root, rel):
275
- p = root / rel
276
- if not p.is_file():
277
- return None
278
- return sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
277
+ """The frontmatter that DECLARES the cited file, and the path it came from.
278
+
279
+ A corpus artifact carries none of its own: `given/x.txt` is bytes, and what
280
+ says how those bytes were obtained is the `x.txt.meta.md` sidecar beside it.
281
+ Resolving the cited path alone returned `{}` for such a file -- which the
282
+ caller cannot tell apart from "resolved, field absent" -- and that made
283
+ DERIVED, RULING and IMPORTED impossible for every claim citing `given/`.
284
+
285
+ Sidecar FIRST when one exists, the cited file otherwise. Not "non-.md ->
286
+ sidecar": a verbatim `.md` source stored in `given/` is declared by its
287
+ sidecar exactly like a `.txt` extraction, and reading its own frontmatter
288
+ would read the SOURCE's, which says nothing about how it was extracted. A
289
+ `corpus/notes/*.md` note has no sidecar and still resolves to its own.
290
+
291
+ Returns `(meta, label)`. The label names the file actually read, so a
292
+ finding can say where it looked instead of sending the reader to the wrong
293
+ file -- the same defect this release fixes in the duplicate-id message.
294
+ """
295
+ base = sdlc_core.confine_under(root, rel)
296
+ if base is None or base == root.resolve():
297
+ # None: the path escapes the docs root, and the source loop said so.
298
+ # Equal to the root: an empty or dot-only path cell (a source written
299
+ # `#L1-2`, with no file before the locator). Forming a sidecar name
300
+ # from that would step back OUT of the root through `base.parent` and
301
+ # read `<docs-root>.meta.md` -- outside the tree this helper confines.
302
+ return None, rel
303
+ if not base.is_file():
304
+ # The cited file is gone. Its sidecar may still be lying there, but a
305
+ # claim on a missing artifact is already an error from the source loop,
306
+ # and answering from an orphan sidecar would add a second finding about
307
+ # a file that is not there.
308
+ return None, rel
309
+ side = base.parent / (base.name + ".meta.md")
310
+ if side.is_file():
311
+ return (sdlc_core.load_frontmatter(
312
+ sdlc_core.read_text(side).splitlines()) or {}, rel + ".meta.md")
313
+ return (sdlc_core.load_frontmatter(
314
+ sdlc_core.read_text(base).splitlines()) or {}, rel)
315
+
316
+
317
+ def _kb_pointer_resolves(p):
318
+ """A recorded pointer resolves when it names a FILE we can stat.
319
+
320
+ `is_file()` and not `exists()`: `original_path` names a document, so a
321
+ directory that happens to sit at that path is not the original. Any OSError
322
+ is "does not resolve" and never a traceback -- the field points OUTSIDE the
323
+ docs root by design, so the validator has to survive whatever lives there
324
+ (an unreadable parent on a network vault, a name too long, a reparse point).
325
+ """
326
+ try:
327
+ return p.is_file()
328
+ except OSError:
329
+ return False
330
+
331
+
332
+ def _kb_original_candidates(op, root):
333
+ """Every place a recorded `original_path` could legitimately be, in order.
334
+
335
+ `Path.is_absolute()` is NOT the test, and using it was a real defect: on
336
+ Windows a rooted-but-driveless path -- `/vault/manuals/xyz.pdf`, the exact
337
+ form this project's own templates print -- is not absolute, so it was
338
+ joined under the docs root and silently rewritten onto the docs root's
339
+ DRIVE. That produced a warning quoting a path nobody wrote, and could hide
340
+ a genuinely dangling pointer behind a file that happened to exist there.
341
+ Anything EITHER platform calls rooted is now taken as written; only a
342
+ genuinely relative pointer is joined.
343
+
344
+ A relative pointer is tried against the docs root's parent (the project
345
+ root in the standard layout) and against the docs root itself, because
346
+ `--root` and `migrate` both allow a docs root that does not sit directly
347
+ under the project root.
348
+ """
349
+ raw = (op or "").strip()
350
+ forms = [raw]
351
+ if "\\" in raw:
352
+ # A path authored on Windows and read anywhere. Tried as a SECOND form,
353
+ # never instead of the first: a backslash is a legal character in a
354
+ # POSIX filename, and rewriting it unconditionally invented a path.
355
+ forms.append(raw.replace("\\", "/"))
356
+ out, seen = [], set()
357
+ for f in forms:
358
+ cands = ([Path(f)] if (ntpath.isabs(f) or posixpath.isabs(f))
359
+ else [root.parent / f, root / f])
360
+ for c in cands:
361
+ if str(c) not in seen:
362
+ seen.add(str(c))
363
+ out.append(c)
364
+ return out
279
365
 
280
366
 
281
367
  def kb_check_claims(root):
@@ -287,6 +373,15 @@ def kb_check_claims(root):
287
373
  return errors, warnings, notes
288
374
  all_ids = {} # id -> "file:line"
289
375
  all_rows = {} # id -> (row, rel)
376
+ # Frontmatter is now resolved for every row, GIVEN included, so a ledger
377
+ # citing one artifact from 80 rows would otherwise stat and decode that
378
+ # artifact's sidecar 80 times. Keyed by the cited path, per run.
379
+ fm_cache = {}
380
+
381
+ def _declaring_frontmatter(rel):
382
+ if rel not in fm_cache:
383
+ fm_cache[rel] = _note_frontmatter(root, rel)
384
+ return fm_cache[rel]
290
385
  per_file_rows = []
291
386
  for p in sorted(topics.glob("*.md")):
292
387
  rel = "topics/" + p.name
@@ -320,25 +415,46 @@ def kb_check_claims(root):
320
415
  kb_check_locator(target, loc, where, errors)
321
416
  # --- provenance ---
322
417
  prov = row["prov"]
418
+ # Resolved ONCE, for every provenance: the non-GIVEN classes read it
419
+ # for their required field, and GIVEN reads it to notice that the
420
+ # artifact declares a weaker chain than the row claims.
421
+ meta, meta_where = (None, "")
422
+ if "#" in first:
423
+ meta, meta_where = _declaring_frontmatter(first.rsplit("#", 1)[0])
323
424
  if prov not in PROVENANCES:
324
425
  errors.append("%s: prov %r not in %s" % (where, prov, "/".join(PROVENANCES)))
325
426
  elif prov in ("DERIVED", "RULING", "ELICITED", "IMPORTED"):
326
- meta = _note_frontmatter(root, first.rsplit("#", 1)[0])
327
427
  if meta is None:
328
428
  pass # unresolvable source already reported
329
429
  elif prov == "DERIVED" and not meta.get("derived_from"):
330
- errors.append("%s: DERIVED claim's note carries no 'derived_from:' "
331
- "— model knowledge disguised as a source" % where)
430
+ errors.append("%s: DERIVED claim's source (%s) carries no "
431
+ "'derived_from:' — model knowledge disguised as "
432
+ "a source" % (where, meta_where))
332
433
  elif prov == "RULING" and not meta.get("basis"):
333
- errors.append("%s: RULING note carries no 'basis:' — a preference "
334
- "is not a fact; no basis, no ruling" % where)
434
+ errors.append("%s: RULING source (%s) carries no 'basis:' — a "
435
+ "preference is not a fact; no basis, no ruling"
436
+ % (where, meta_where))
335
437
  elif prov == "IMPORTED" and not meta.get("imported_from"):
336
438
  # F-030: IMPORTED exists so a foreign decision cannot pass for
337
439
  # a local one. Without the origin the class says nothing and
338
440
  # the row is a RULING with the label filed off.
339
- errors.append("%s: IMPORTED note carries no 'imported_from:' — "
340
- "the class exists to name whose decision this "
341
- "was; unnamed, it is a RULING in disguise" % where)
441
+ errors.append("%s: IMPORTED source (%s) carries no "
442
+ "'imported_from:' — the class exists to name "
443
+ "whose decision this was; unnamed, it is a "
444
+ "RULING in disguise" % (where, meta_where))
445
+ elif prov == "GIVEN":
446
+ # F-035: a row resting on an OCR, a transcription or a
447
+ # translation is not the same evidence as one resting on a
448
+ # deterministic text layer. The sidecar could say so only in
449
+ # prose, and prose is not a check -- which is how three rows
450
+ # whose evidence was a reading of an image passed as GIVEN.
451
+ declared = ((meta or {}).get("provenance") or "").strip()
452
+ if declared and declared.upper() != "GIVEN":
453
+ warnings.append(
454
+ "%s: prov GIVEN, but %s declares 'provenance: %s' — the "
455
+ "row reads as first-hand evidence and its artifact does "
456
+ "not. File the row at the provenance the chain actually "
457
+ "has, or correct the sidecar" % (where, meta_where, declared))
342
458
  # --- grammar cells ---
343
459
  try:
344
460
  kb_parse_scope(row["valid"])
@@ -368,9 +484,49 @@ def kb_check_claims(root):
368
484
  "moved silently" % (where, row["id"], expect))
369
485
  if row["id"]:
370
486
  if row["id"] in all_ids:
371
- errors.append("%s: duplicate id %s (also at %s) uniqueness "
372
- "is global across topics/" % (where, row["id"],
373
- all_ids[row["id"]]))
487
+ # F-035: one message served two different defects. Same id
488
+ # with the SAME text is a copied row. Same id with DIFFERENT
489
+ # text is a collision: kb_claim_id hashes path#locator#qty
490
+ # and excludes the text on purpose, so two distinct
491
+ # assertions about one span cannot be told apart. The source
492
+ # and qty do NOT discriminate -- the id already implies them.
493
+ prev_row = all_rows[row["id"]][0]
494
+
495
+ def _cell(r, k):
496
+ return (r[k] or "").strip()
497
+
498
+ def _first_src(r):
499
+ return (r["source"] or "").split(";")[0].strip()
500
+
501
+ same_text = _cell(prev_row, "claim") == _cell(row, "claim")
502
+ same_span = (_first_src(prev_row) == _first_src(row)
503
+ and _cell(prev_row, "qty") == _cell(row, "qty"))
504
+ if same_text:
505
+ errors.append("%s: duplicate id %s (also at %s) — uniqueness "
506
+ "is global across topics/" % (where, row["id"],
507
+ all_ids[row["id"]]))
508
+ elif same_span:
509
+ errors.append(
510
+ "%s: id %s collides with the row at %s — two "
511
+ "DIFFERENT rows cite the same span with the same "
512
+ "qty, and the id function cannot separate them (it "
513
+ "hashes path#locator#qty and excludes the text on "
514
+ "purpose). Widen one locator to the span that "
515
+ "actually carries its assertion, or merge the two "
516
+ "rows — do not edit the qty to break the tie"
517
+ % (where, row["id"], all_ids[row["id"]]))
518
+ else:
519
+ # Same id, different text AND different span: the id
520
+ # cannot have been computed from both rows, so it was
521
+ # hand-typed or left stale after a source was repointed.
522
+ # Saying "they cite the same span" here would name a
523
+ # cause that is provably not this one.
524
+ errors.append(
525
+ "%s: id %s is also on the row at %s, which cites a "
526
+ "different span — the id was not computed from this "
527
+ "row (hand-typed, or left stale after its source "
528
+ "moved). Run 'sdlc_check.py claim-id --fill %s'"
529
+ % (where, row["id"], all_ids[row["id"]], rel))
374
530
  else:
375
531
  all_ids[row["id"]] = where
376
532
  all_rows[row["id"]] = (row, rel)
@@ -889,6 +1045,24 @@ def kb_corpus_check(root):
889
1045
  errors.append("%s: raw-byte digest changed since ingest — "
890
1046
  "given/ is never edited; this is the check, "
891
1047
  "not a convention" % rel)
1048
+ # F-035: `original_sha256` is not verified because we do not hold
1049
+ # the bytes -- a limit stated out loud in distillation.md. That
1050
+ # reason does not extend to the PATH, which costs one exists(). A
1051
+ # corpus whose premise is "every provenance is a real file" cannot
1052
+ # let 16 sidecars go dangling behind a green run.
1053
+ op = (meta.get("original_path") or "").strip()
1054
+ if op:
1055
+ cands = _kb_original_candidates(op, root)
1056
+ tried = [str(c) for c in cands]
1057
+ resolved = any(_kb_pointer_resolves(c) for c in cands)
1058
+ if not resolved:
1059
+ warnings.append(
1060
+ "%s: original_path %r does not resolve (tried %s) — the "
1061
+ "extraction is intact, the pointer to the original is "
1062
+ "not. A warning and not an error: a bundle carries "
1063
+ "artifacts and sidecars, never the originals, so after "
1064
+ "an import this dangles legitimately"
1065
+ % (rel, op, ", ".join(tried)))
892
1066
  sup = (meta.get("supersedes") or "").strip()
893
1067
  if sup:
894
1068
  superseded.add(sup)
@@ -668,8 +668,34 @@ original_sha256: <digest at ingest — RECORDED, never checked: we do not hold t
668
668
  ---
669
669
  ```
670
670
 
671
- Keep `original_sha256`'s limit visible wherever it is written: it lets a human
672
- re-verify by hand and it dates the ingest, and it detects nothing on its own.
671
+ The two fields are **not** in the same position, and writing them as if they were is
672
+ how a dangling pointer survives a green run:
673
+
674
+ - `original_sha256` detects nothing on its own — we do not hold the bytes. It lets a
675
+ human re-verify by hand and it dates the ingest. Keep that limit visible wherever the
676
+ field is written.
677
+ - `original_path` **is** checked for resolution, and warns when it does not resolve
678
+ (never errors — an imported bundle carries no originals). **Write it absolute, or
679
+ relative to the project root**; the validator tries the docs root's parent first and
680
+ the docs root second, and names both in the warning. The convention was implicit
681
+ until F-035 and is stated here because a pointer nobody can resolve is worth less
682
+ than no pointer at all.
683
+
684
+ Write these two values with **no trailing `# comment`**: the frontmatter reader does
685
+ not strip inline comments, so the comment lands inside the value.
686
+
687
+ `provenance:` has a consumer too: a claim row filed `prov: GIVEN` whose artifact's
688
+ sidecar declares anything else warns. Declare the chain the artifact actually has —
689
+ an OCR, a transcription from an image, a translation are not first-hand evidence, and
690
+ saying so in the sidecar's prose is not a check.
691
+
692
+ Two limits of that warning, stated so it is not mistaken for more than it is. It reads
693
+ the row's **first** source, so a weak artifact cited second is not compared. And it can
694
+ only read the **field**: a sidecar that says `provenance: GIVEN` while its prose says
695
+ "transcribed from a photograph" is silent, because prose is not machine-readable — the
696
+ warning catches the author who declared the chain honestly and then filed the row too
697
+ strongly, never the author who declared it wrongly. Nothing requires a `given/` sidecar
698
+ to carry `provenance:` at all; adding that requirement is a new gate, not this one.
673
699
 
674
700
  ## ai_docs/corpus/notes/RULING_[topic]_[date].md (practitioner ruling)
675
701