@antoneeo/kb-agentic-skill 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,91 @@
2
2
 
3
3
  Every significant change to this skill is recorded here.
4
4
 
5
+ ## [1.3.0] - 2026-08-03
6
+
7
+ Field defect: an agent handed a 200-page manual emits a few dozen claims and reports
8
+ done. **Nothing it did broke a rule** — the extraction discipline carried a floor ("the
9
+ extractor invents nothing") and no target, so an agent stops the moment nothing it wrote
10
+ is false, and every row it emitted is correct.
11
+
12
+ ### Added
13
+ - **A north star above the rules** in `distillation.md`: *not one assertion the source
14
+ makes may be lost, and not one it does not make may appear.* One sentence on purpose —
15
+ the halves counterweight each other, and two rules a paragraph apart get optimized
16
+ whichever was read last. The unit is the assertion, not the byte: exhaustive means
17
+ **read**, never *a row per page*.
18
+ - **`extracted_through:`** on the artifact's sidecar (`p=<n>`, `L<n>`, `complete`),
19
+ required once any claim cites it — the thing that makes "I am finished" falsifiable.
20
+ Claims with no coverage recorded error; a claim addressing past the declared coverage,
21
+ or coverage past the end of the stored bytes, is a contradiction and errors; coverage
22
+ short of the end warns, since partial work is legal mid-ingestion.
23
+ - **Bounded reading windows** (30 pages by default; the plan states the window used),
24
+ one plan task each. The existing `PLAN_` ledger is the register an ingestion resumes
25
+ from across sessions — no second register was built.
26
+ - **A coverage cell in `corpus/INDEX.md` for every artifact**, finished ones included: a
27
+ list of only what is behind would be the work-management dashboard this method refuses.
28
+
29
+ The limit is written where the field is: **nothing proves a page was read.** A field
30
+ advanced without extracting is invisible to any checker, because a page that asserts
31
+ nothing legitimately yields no rows — that direction belongs to the ingestion review.
32
+ What changed is that the shortcut must be written down to pass.
33
+
34
+ **Upgrading an existing corpus:** `check` errors on every artifact that has claims and
35
+ no `extracted_through:`. State how far each source was actually read (`complete` if it
36
+ was finished); the message names the artifact and the first row citing it. Run
37
+ `sdlc_check.py index` once as well — `corpus/INDEX.md` gains the coverage cell.
38
+
39
+ ## [1.2.0] - 2026-08-03
40
+
41
+ ### Added
42
+ - **`export --out <dir>` / `import <dir>`** — knowledge built in one project can be
43
+ carried into another. The export is a **closure**, not a selection: the bundle carries
44
+ the bytes its claims cite (a claim whose source cannot be reopened is model knowledge
45
+ arriving by another route) and pulls in the other half of any `CONTESTED` set, saying
46
+ which topics it added. The import is **additive and all-or-nothing**: it never
47
+ overwrites a node, never deletes, and computes the whole plan before writing a byte.
48
+ Duplicate claims are recognised by id, not by comparing text.
49
+ - **`prov: IMPORTED`.** Knowledge crosses the project boundary; authority does not. An
50
+ imported ruling keeps its text, span and original `basis:` verbatim, must declare
51
+ `imported_from:`, and **cannot supersede a local row** until you re-ratify it with your
52
+ own note and your own basis.
53
+ - **`portability.md`** — the doctrine those two commands cite, including what to tell the
54
+ user in their own words.
55
+
56
+ ## [1.1.1] - 2026-08-03
57
+
58
+ ### Fixed
59
+ Three doctrine-vs-machinery inconsistencies found by a practitioner reading 1.1.0 —
60
+ the worst defect class, because the agent verifies and is confirmed in a false belief.
61
+ - `SKILL.md` never named `anchor`, so the command existed and the agent could not find it.
62
+ - The `corpus/given/*` Write Trigger still carried its pre-1.1.0 wording, contradicting
63
+ the extraction-as-artifact rule it points at.
64
+ - `anchor` resolved paths only from inside the docs root, unlike every sibling command.
65
+
66
+ ## [1.1.0] - 2026-08-03
67
+
68
+ ### Fixed / Added
69
+ Six findings from the first full application of this skill by a practitioner other than
70
+ its author (51 artifacts, 82 claims). None was an adherence failure: the agent obeyed
71
+ every rule and the outcome was still wrong.
72
+ - **Triage restated in knowledge units.** The levels were undecidable in this domain
73
+ because they carried the code lens's file counts. The unit here is knowledge, never
74
+ file count — with one limit: propagation that changes what a claim asserts is not
75
+ propagation.
76
+ - **Gates are extracted alongside powers.** For every row saying what the subject *can
77
+ do*, the source is asked what must hold first — default-off, licence tier, version
78
+ floor, dependency — because "yes, supported" without the gate is a plan that fails on
79
+ site. The rule stays *ask*, never *produce*.
80
+ - **`anchor <path> <phrase>`** turns a quoted phrase into a verified locator, matching
81
+ whitespace as `\s+` because a PDF extraction breaks phrases mid-line — the gap that
82
+ cost a field user two generation rounds.
83
+ - **Extraction-as-artifact** for large binary corpora: the extraction is the artifact,
84
+ the digest moves onto the bytes locators actually address, and the original stays where
85
+ it lives as `original_path:` + `original_sha256:` (recorded, never checked — the limit
86
+ is stated wherever the fields are).
87
+ - **`--help` lists the overlay commands**, so the ones this lens adds are discoverable
88
+ from the CLI rather than only from the documentation.
89
+
5
90
  ## [1.0.1] - 2026-08-02
6
91
 
7
92
  ### Fixed
package/README.md CHANGED
@@ -12,13 +12,15 @@ Two axes are kept apart on purpose — **abstraction** (a topic made of topics:
12
12
 
13
13
  2. **Extraction — the unit is the claim.** Rows of `id | claim | valid | qty | about | source | prov | state`. The **id hashes the location and the quantity, never the text**, so an LLM rephrasing mints no new identity. The locator (`p=17@412-509`) is verified: the validator opens the extraction and checks the span exists — and `anchor <path> <phrase>` produces it for you, matching spaces as `\s+` because a PDF extraction breaks phrases mid-line. **Gates are extracted alongside powers**: for every row saying what something can do, the source is asked what must hold first — default-off, licence tier, version floor, dependency — because "yes, supported" without the gate is a plan that fails on site. The rule is *ask*, never *produce*: a source that states no gate yields no row. Validity scopes are half-open ("until March" and "from March" do not conflict). Quantities are typed — mixed kinds or currencies **refuse to sum**. What the source does not assert becomes a `gaps:` line, never a claim.
14
14
 
15
+ **The source is exhausted, not sampled.** "Invents nothing" is a floor, and an extractor that stops when nothing it wrote is false stops on page twenty of a two-hundred-page manual with every row correct — which is exactly what a 200-page manual produces in the field. So a long source is read in **bounded windows** (30 pages by default, one plan task each, the ledger holding your place across sessions), and every window closes by advancing `extracted_through:` on the artifact's sidecar. That field is what makes "I am finished" falsifiable: claims with no coverage recorded are an error, a claim addressing a page past the declared coverage is a contradiction, and coverage short of the end is reported until it reaches it. Its limit is stated where it is written — **nothing proves a page was read**; what changes is that the shortcut must now be written down to pass. And exhaustive means read, never *a row per page*: a page that asserts nothing yields nothing.
16
+
15
17
  3. **Placement — five verdicts, after querying the graph.** Descent through the generated index following every parent (polyhierarchy). EXISTS → reconcile; INADEQUATE → child; **MISSING only after the graph was actually asked**; GENERALIZES → escalate (a new root stops at you); UNPLACED → quarantine. Similar-but-maybe-different becomes a sibling **with the distinguishing line written** — if you cannot write it, it is the same concept. Cycles are refused at write time; merged nodes leave a tombstone with `redirect_to:`, never a deletion.
16
18
 
17
19
  4. **Reconciliation — the machine detects and holds, it never decides.** Five outcomes: new / confirmation (the source is appended to the row — the base strengthens, it does not lengthen) / refinement (the old row goes `SUPERSEDED`, its text intact) / coexistence (disjoint scopes) / conflict → the whole set goes `CONTESTED`, **symmetrically**: flipping one cell by hand fails the check. Only new information resolves it — a later source, or **your ruling with a `basis:`**, the fact you know and the corpus does not. No basis, no ruling: a preference is not a fact. A ruling is challengeable — a later document reopens the case with your basis beside it.
18
20
 
19
21
  5. **Escalation in one batch at the end of the run**, in legal form (the claims, the reopenable sources, the dates, why the machine cannot decide). Ingestion never stops to interrogate you.
20
22
 
21
- Deliberately absent: any per-node coverage or completion state. `gaps:` says what a node lacks; nothing collects it into a dashboard.
23
+ Deliberately absent: any per-node coverage or completion state. `gaps:` says what a node lacks; nothing collects it into a dashboard. A **source** does record how far it has been read, but on its own sidecar and nowhere else — the corpus index prints that fact for every artifact, including the finished ones, because a list of only what is behind is the dashboard this method refuses.
22
24
 
23
25
  ## Key features
24
26
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kb-agentic-skill",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Knowledge-Base & Document-First protocol with risk triage, Vision governance, signal distillation and optional devPNT integration.",
5
5
  "author": "Antonio Pinto (https://github.com/Antoneeo)"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antoneeo/kb-agentic-skill",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Knowledge-Base & Document-First protocol for Claude Code, Gemini CLI, Google Antigravity and Codex with risk triage, Vision governance, signal distillation and optional devPNT integration.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -13,7 +13,7 @@ This skill guides knowledge management and documentation with a Document-First p
13
13
 
14
14
  Support files in the skill directory:
15
15
  - `templates.md`: templates for Vision, Knowledge ANALYSIS, Research SPIKE, SOP GUIDE, audit plan, and handoff.
16
- - `portability.md`: carrying knowledge between projects — what a bundle is, why export is a closure rather than a selection, and the rule that knowledge crosses a project boundary while authority does not (`prov: IMPORTED`). Read before `export`/`import`.
16
+ - `portability.md`: carrying knowledge between projects — what a bundle is, why export is a closure rather than a selection, and the rule that knowledge crosses a project boundary while authority does not (`prov: IMPORTED`). Read before `export`/`import`.
17
17
  - `taxonomy.md`: placing a claim in the topic graph — descent over the generated index, the five verdicts (EXISTS / INADEQUATE / MISSING / GENERALIZES / UNPLACED), the sibling rule, guarded re-parenting, canonicalization. Run at L3 before drafting.
18
18
  - `guides.md`: pipeline for distilling user-provided indications into `ai_docs/reference/GUIDE_[topic].md`.
19
19
  - `vision.md`: how to write a Vision a cold reviewer can actually apply — the properties that make a rule hold, the minimum operable sections, and the blind check.
@@ -84,9 +84,11 @@ Triage decides IF documentation is due; this table decides WHICH document each e
84
84
  | `strategic/architecture.md`, `strategic/existing_features.md` | Bootstrap; update at closure when the knowledge catalog actually changed. | 1 / 5 |
85
85
  | `vision/project_vision.md`, `roadmap.md`, `principles.md` | Bootstrap as `Status: DRAFT`; promoted to APPROVED only by explicit user confirmation. | 1 / 2 |
86
86
  | `topics/<slug>.md` | A placement verdict creates it (MISSING/INADEQUATE-child, `taxonomy.md`); reconciliation updates its claim rows. One node per topic — a similar-but-distinct concept is a sibling with `related:` + a written distinction, never a merge and never a duplicate. Merged/renamed nodes become tombstones (`status: SUPERSEDED` + `redirect_to:`), never deleted. | 4 |
87
- | a KB bundle (`export`) | Knowledge must leave this project. Export is L1 — it writes nothing into the corpus. **Importing one is L3**: a source enters the corpus and nodes are created, and a bundle is external input, so never L1 whatever its size (`portability.md`). | — |
87
+ | a KB bundle (`export`) | Knowledge must leave this project. Export is L1 — it writes nothing into the corpus. **Importing one is L3**: a source enters the corpus and nodes are created, and a bundle is external input, so never L1 whatever its size (`portability.md`). | — |
88
88
  | `corpus/given/*` + sidecar | A source arrives: it becomes a content-addressed artifact with a sidecar carrying digest/date/`supersedes:`. A text source is copied verbatim. A non-text source yields its stored canonical extraction — copied **beside** the original when that is small enough to keep, or **instead of it** on a large binary corpus, where the original stays where it lives and is recorded as `original_path:`/`original_sha256:` (`distillation.md` §1, which owns this rule). Never edited after ingest — the digest check on whatever `given/` holds is what enforces it. | 4 |
89
89
  | `corpus/notes/*` | Something is said (`origin: elicited`), synthesised (`derived_from:`), or ruled (`basis:`). A note with none of the three is refused by the validator. | 4 / 5 |
90
+ | `solutions/PLAN_[topic].md` + its ledger | A source too long for one context is ingested: **one task per reading window** (30 pages by default; the plan states the window used), each ending at a declared `extracted_through:`. That ledger is the register the ingestion resumes from across sessions — ingestion never builds a second one (`distillation.md` §3, `templates.md`). | 4 |
91
+ | `corpus/given/*.meta.md` — `extracted_through:` | Every reading window closes by advancing it (`p=<n>`, `L<n>`, `complete`). Required once any claim cites the artifact: unstated, "I am finished" cannot be falsified, which is how a sampled 200-page manual passes for an ingested one. | 4 |
90
92
  | `INDEX.md`, `reference/INDEX.md`, `topics/INDEX.md`, `corpus/INDEX.md` | Regenerated by `sdlc_check.py index` at closure — never by hand; `validate` fails on a hand-edited one. | 5 |
91
93
 
92
94
  ## Operating Modes
@@ -6,6 +6,19 @@ of knowledge get its file".
6
6
  **Does not answer**: where a claim's concept lives (above — `taxonomy.md`) or what
7
7
  happens when it disagrees with an existing one (`reconciliation.md`).
8
8
 
9
+ > **North star.** *Not one assertion the source makes may be lost, and not one it does
10
+ > not make may appear.* These are one rule, not two: a ledger that invents nothing but
11
+ > keeps a tenth of the manual is as useless as one that keeps everything and made half
12
+ > of it up.
13
+ >
14
+ > The unit is the **assertion**, not the byte: layout, ordering, repetition and page
15
+ > furniture are not assertions. A page that asserts nothing yields nothing — exhaustive
16
+ > means **read**, never *a row per page*. And "I am finished" is an assertion like any
17
+ > other: `extracted_through:` is what makes it falsifiable.
18
+
19
+ Everything below is that one rule made operable. Where a rule below does not reach the
20
+ case in front of you, decide by the north star — both halves of it, in the same breath.
21
+
9
22
  ## 1. Intake — everything becomes a file first
10
23
 
11
24
  Ingest **never touches the graph**. First the source enters the corpus; the graph is fed
@@ -19,8 +32,9 @@ from the corpus, so everything is re-derivable when the rules improve.
19
32
  | an agent synthesis | a note in `corpus/notes/` with `derived_from:` listing its sources — a note with neither `origin:` nor `derived_from:` nor `basis:` is **model knowledge disguised as a source**, and the validator refuses it |
20
33
  | a practitioner ruling | a note with `basis:` (`reconciliation.md`) |
21
34
 
22
- Every sidecar (`<artifact>.meta.md`) carries: the digest, the date, provenance, and
23
- `supersedes:` when it replaces an earlier version. `corpus/INDEX.md` is generated.
35
+ Every sidecar (`<artifact>.meta.md`) carries: the digest, the date, provenance,
36
+ `supersedes:` when it replaces an earlier version, and `extracted_through:` once anything
37
+ has been extracted from it (§3). `corpus/INDEX.md` is generated.
24
38
 
25
39
  **Extraction-as-artifact — the variant for a large binary corpus.** Copying gigabytes
26
40
  of PDFs into the docs root buys nothing: what the digest protects is *the bytes a
@@ -81,6 +95,33 @@ the offset span it came from. The extractor **invents nothing**: no labels, no
81
95
  summaries-as-claims, no filling of gaps from model knowledge. What the source does not
82
96
  assert does not become a row — it may become a `gaps:` entry on the topic.
83
97
 
98
+ **Exhaust the source; never sample it.** *Invents nothing* is a floor, and a floor is not
99
+ a target: an extractor that stops the moment nothing it wrote is false stops on page
100
+ twenty of a two-hundred-page manual, with every row it emitted correct. A source is
101
+ finished when every page has been **read**, not when enough rows exist. The claim count
102
+ settles nothing in either direction — a short source legitimately yields few rows and a
103
+ dense one yields many — so it can never distinguish *finished* from *sampled*.
104
+
105
+ **Read in a bounded window, and let the plan hold your place.** A long source does not fit
106
+ in one context, and an agent that runs out of room has two moves: summarize, or stop
107
+ silently. It summarizes. So read a fixed span, emit that span's rows, and only then move
108
+ on. **30 pages is the default and the plan states the window actually used** — page
109
+ density and context budgets differ per source and per model, so the number is declared
110
+ per ingestion rather than assumed. The window is also the unit of resumption: one plan
111
+ task per window (below), so a session that ends mid-source resumes at the next task
112
+ instead of re-reading or guessing where it stopped.
113
+
114
+ **Close each window by advancing `extracted_through:`** on the artifact's sidecar
115
+ (`templates.md`) — `p=<n>`, `L<n>`, or `complete` when the last page is read. It is what
116
+ makes "I am finished" falsifiable, and it makes the rows and the field check each other:
117
+ a claim whose locator addresses past the declared coverage is a contradiction, and the
118
+ validator reports it. Say the limit out loud, as with `original_sha256` above:
119
+ **nothing here proves a page was read.** A field advanced without extracting is not
120
+ mechanically detectable, precisely because a page that asserts nothing legitimately
121
+ yields no rows — that direction is verified at the ingestion review (`review.md`), like
122
+ the gates below. What the field buys is that the shortcut must now be **written down** to
123
+ pass, and a written claim can be reopened by anyone who cares to.
124
+
84
125
  **Cover the gates, not only the powers.** "One row per assertion" is obeyable and still
85
126
  leaves the ledger optimistic: reading a capability paragraph, an extractor emits the
86
127
  capability and moves on, because that is what the paragraph asserts. So for every row
@@ -110,10 +151,17 @@ locator without it is how two generation rounds get lost.
110
151
 
111
152
  Extraction is read-only on the corpus and blind to the graph: rows go to placement
112
153
  (`taxonomy.md`) afterwards. Ingesting a document set is **L3 by Rule Zero**; the
113
- ingestion plan derives from the ANALYSIS Action Plan, one task per source, each task's
114
- `verify` = "claim rows parse; every source resolves under the docs root". Serial in v1:
154
+ ingestion plan derives from the ANALYSIS Action Plan, **one task per reading window**
155
+ a source that fits one window is one task, a 200-page manual at 30 pages is seven — each
156
+ task's `verify` = "claim rows parse; every source resolves under the docs root; the
157
+ sidecar's `extracted_through:` reaches this window's last page". Serial in v1:
115
158
  correctness first.
116
159
 
160
+ The plan's ledger already records `status: done` per task (`templates.md`), which is the
161
+ register that survives a session boundary — so ingestion **builds no second one**. What
162
+ has been covered is recorded (the sidecar, the ledger); what remains is derived from
163
+ them, never stored.
164
+
117
165
  ## 4. Signal discipline (what "distillation" still means)
118
166
 
119
167
  - Contract-first for any prose you write around the rows (reader, action, payload).
@@ -92,6 +92,8 @@ DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
92
92
  LOC_PAGE_RE = re.compile(r"^p=(\d+)@(\d+)-(\d+)$")
93
93
  LOC_LINE_RE = re.compile(r"^L(\d+)-(\d+)$")
94
94
  LOC_CELL_RE = re.compile(r"^Sheet[^!]+![A-Z]+\d+$")
95
+ # F-031. How far a source has been read, in the unit its locators address.
96
+ EXTRACTED_THROUGH_RE = re.compile(r"^(?:complete|p=(\d+)|L(\d+))$")
95
97
 
96
98
  # Unit conventions, documented in templates.md. effort in person-days
97
99
  # (8h day, 5d week, 21d month); duration in calendar days; cost within ONE
@@ -664,6 +666,64 @@ def kb_build_topic_index(root):
664
666
  return "\n".join(lines) + "\n"
665
667
 
666
668
 
669
+ def kb_parse_extracted_through(value):
670
+ """('complete', None) | ('p', n) | ('L', n), or None when the value is not a
671
+ coverage statement.
672
+
673
+ Fail-closed on purpose: a field whose whole job is to be checkable must be
674
+ checkable, so an unreadable value is an error rather than a silent pass."""
675
+ m = EXTRACTED_THROUGH_RE.match((value or "").strip())
676
+ if not m:
677
+ return None
678
+ if m.group(1):
679
+ return "p", int(m.group(1))
680
+ if m.group(2):
681
+ return "L", int(m.group(2))
682
+ return "complete", None
683
+
684
+
685
+ def kb_extraction_extent(artifact, kind):
686
+ """How far the stored bytes go, in the unit `kind` — or None when nothing
687
+ measurable is stored.
688
+
689
+ Opens exactly the file `kb_check_locator` opens for that locator form: pages
690
+ live in the stored extraction beside the original, lines in the artifact
691
+ itself. Coverage is therefore measured against the same bytes a locator
692
+ addresses, and a binary is never read as text (a .pdf with no extraction
693
+ beside it is simply unmeasurable — the p= branch needs the .txt)."""
694
+ if kind == "p":
695
+ ext = artifact if artifact.suffix == ".txt" else artifact.with_suffix(".txt")
696
+ if not ext.is_file():
697
+ return None
698
+ return len(sdlc_core.read_text(ext).split("\f"))
699
+ if kind == "L":
700
+ if not artifact.is_file():
701
+ return None
702
+ return sdlc_core.read_text(artifact).count("\n") + 1
703
+ return None
704
+
705
+
706
+ def kb_coverage_cell(artifact, through):
707
+ """The coverage fact for one corpus row.
708
+
709
+ EVERY artifact gets one, including the finished ones: printing only the
710
+ incomplete ones would turn this index into 'the set that is not current',
711
+ which the Vision refuses (r9). It is a fact on an existing row, never a
712
+ filter and never a sort key."""
713
+ if not (through or "").strip():
714
+ return "extraction not recorded"
715
+ parsed = kb_parse_extracted_through(through)
716
+ if parsed is None:
717
+ return "extracted through %s (unreadable)" % through.strip()
718
+ kind, n = parsed
719
+ if kind == "complete":
720
+ return "extracted through complete"
721
+ total = kb_extraction_extent(artifact, kind)
722
+ stated = ("p=%d" % n) if kind == "p" else ("L%d" % n)
723
+ return "extracted through %s of %d" % (stated, total) if total \
724
+ else "extracted through %s" % stated
725
+
726
+
667
727
  def kb_build_corpus_index(root):
668
728
  """One row per corpus artifact, from sidecars and note frontmatter."""
669
729
  corpus = root / "corpus"
@@ -676,9 +736,10 @@ def kb_build_corpus_index(root):
676
736
  meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
677
737
  orig = meta_p.name[:-len(".meta.md")]
678
738
  sup = (meta.get("supersedes") or "").strip()
679
- lines.append("- `%s` — %s%s" % (
739
+ lines.append("- `%s` — %s%s — %s" % (
680
740
  orig, (meta.get("date") or "undated"),
681
- (" — supersedes `%s`" % sup) if sup else ""))
741
+ (" — supersedes `%s`" % sup) if sup else "",
742
+ kb_coverage_cell(given / orig, meta.get("extracted_through"))))
682
743
  notes = corpus / "notes"
683
744
  if notes.is_dir():
684
745
  lines.append("")
@@ -692,12 +753,125 @@ def kb_build_corpus_index(root):
692
753
  return "\n".join(lines) + "\n"
693
754
 
694
755
 
756
+ def kb_cited_extents(root):
757
+ """Per artifact file name, what the claim rows say about it: the highest page
758
+ and the highest line any locator addresses (with the row that says so), and
759
+ every row citing it. One walk of topics/, shared by the supersession and the
760
+ coverage checks — two walks of the same tree for two questions is how the
761
+ answers start disagreeing."""
762
+ cited = {}
763
+ topics = root / "topics"
764
+ if not topics.is_dir():
765
+ return cited
766
+ for p in sorted(topics.glob("*.md")):
767
+ for row in kb_parse_claims(sdlc_core.read_text(p))[0]:
768
+ where = "topics/%s:%d" % (p.name, row["_line"])
769
+ for src in row["source"].split(";"):
770
+ src = src.strip()
771
+ if not src:
772
+ continue
773
+ path_s, loc = src.rsplit("#", 1) if "#" in src else (src, "")
774
+ parts = Path(path_s.strip().replace("\\", "/")).parts
775
+ if parts[-3:-1] != ("corpus", "given"):
776
+ # Keyed by file name, so a note sharing a name with an
777
+ # artifact would otherwise be attributed to it and inflate
778
+ # its extents. Both consumers here ask only about given/.
779
+ continue
780
+ name = parts[-1]
781
+ e = cited.setdefault(name, {"p": 0, "p_where": None,
782
+ "L": 0, "L_where": None, "rows": []})
783
+ e["rows"].append(where)
784
+ m = LOC_PAGE_RE.match(loc.strip())
785
+ if m and int(m.group(1)) > e["p"]:
786
+ e["p"], e["p_where"] = int(m.group(1)), where
787
+ continue
788
+ m = LOC_LINE_RE.match(loc.strip())
789
+ if m and int(m.group(2)) > e["L"]:
790
+ e["L"], e["L_where"] = int(m.group(2)), where
791
+ return cited
792
+
793
+
794
+ def _kb_cited_for(cited, artifact_name):
795
+ """Claims may cite the original or its stored extraction — both address the
796
+ same bytes, so both count as citing this artifact."""
797
+ names = [artifact_name]
798
+ if not artifact_name.endswith(".txt"):
799
+ names.append(Path(artifact_name).with_suffix(".txt").name)
800
+ found = [cited[n] for n in names if n in cited]
801
+ if not found:
802
+ return None
803
+ merged = dict(found[0])
804
+ for e in found[1:]:
805
+ for kind in ("p", "L"):
806
+ if e[kind] > merged[kind]:
807
+ merged[kind], merged[kind + "_where"] = e[kind], e[kind + "_where"]
808
+ merged["rows"] = merged["rows"] + e["rows"]
809
+ return merged
810
+
811
+
812
+ def kb_check_coverage(rel, artifact, through, facts, errors, warnings):
813
+ """`extracted_through:` against the rows and against the stored bytes (F-031).
814
+
815
+ Four outcomes, and the boundary between them is the whole point: claims with
816
+ no field errors (an unfalsifiable 'done'); a field that contradicts the bytes
817
+ or the rows errors; a field short of the end warns, because partial work is
818
+ legal mid-ingestion; an artifact nobody extracted from stays silent.
819
+
820
+ The limit, stated where the code is: nothing here proves a page was READ. A
821
+ field advanced without extracting is invisible to any checker, since a page
822
+ that asserts nothing legitimately yields no rows — that direction belongs to
823
+ the ingestion review. What this buys is that the shortcut must be written
824
+ down to pass."""
825
+ if not through:
826
+ if facts:
827
+ errors.append("%s: claims cite this artifact and the sidecar has no "
828
+ "'extracted_through:' — how far a source was read is an "
829
+ "assertion like any other, and unstated 'I am finished' "
830
+ "cannot be falsified. Record it: 'p=<n>', 'L<n>', or "
831
+ "'complete' (first row at %s)" % (rel, facts["rows"][0]))
832
+ return
833
+ parsed = kb_parse_extracted_through(through)
834
+ if parsed is None:
835
+ errors.append("%s: extracted_through: %r is not a coverage statement — use "
836
+ "'complete', 'p=<n>' or 'L<n>'" % (rel, through))
837
+ return
838
+ kind, n = parsed
839
+ if kind == "complete":
840
+ return
841
+ other = "L" if kind == "p" else "p"
842
+ unit = "pages" if kind == "p" else "lines"
843
+ total = kb_extraction_extent(artifact, kind)
844
+ if facts and facts[other] and not facts[kind]:
845
+ errors.append("%s: coverage is stated in %s while every claim addresses %s "
846
+ "(%s) — stated in the wrong unit it compares with nothing, "
847
+ "and nothing here is checkable"
848
+ % (rel, unit, "lines" if kind == "p" else "pages",
849
+ facts[other + "_where"]))
850
+ return
851
+ if total and n > total:
852
+ errors.append("%s: extracted_through: %s, past the end of the stored bytes "
853
+ "(%d %s) — coverage cannot exceed what was stored"
854
+ % (rel, through, total, unit))
855
+ elif total and n < total:
856
+ warnings.append("%s: extracted through %s of %d %s — ingestion is incomplete "
857
+ "(legal mid-work: a source is finished when every page has "
858
+ "been read, not when enough rows exist)"
859
+ % (rel, through, total, unit))
860
+ if facts and facts[kind] > n:
861
+ reached = ("p=%d" % facts[kind]) if kind == "p" else ("L%d" % facts[kind])
862
+ errors.append("%s: a claim addresses %s, past the declared coverage %s (%s) — "
863
+ "the sidecar and the rows contradict each other; one of the two "
864
+ "is wrong" % (rel, reached, through, facts[kind + "_where"]))
865
+
866
+
695
867
  def kb_corpus_check(root):
696
- """Corpus integrity: digests, supersession, laundered notes. Findings only."""
868
+ """Corpus integrity: digests, supersession, coverage, laundered notes.
869
+ Findings only."""
697
870
  errors, warnings = [], []
698
871
  corpus = root / "corpus"
699
872
  if not corpus.is_dir():
700
873
  return errors, warnings
874
+ cited = kb_cited_extents(root)
701
875
  superseded = set()
702
876
  given = corpus / "given"
703
877
  if given.is_dir():
@@ -721,6 +895,9 @@ def kb_corpus_check(root):
721
895
  if not (given / sup).is_file():
722
896
  warnings.append("%s: supersedes %r which is not in given/"
723
897
  % (rel, sup))
898
+ kb_check_coverage(rel, orig,
899
+ (meta.get("extracted_through") or "").strip(),
900
+ _kb_cited_for(cited, orig.name), errors, warnings)
724
901
  notes = corpus / "notes"
725
902
  if notes.is_dir():
726
903
  for p in sorted(notes.glob("*.md")):
@@ -730,20 +907,11 @@ def kb_corpus_check(root):
730
907
  errors.append("corpus/notes/%s: neither 'derived_from:' nor "
731
908
  "'origin:' nor 'basis:' — model knowledge disguised "
732
909
  "as a source" % p.name)
733
- # claims resting on superseded originals (UC4)
734
- if superseded:
735
- topics = root / "topics"
736
- if topics.is_dir():
737
- for p in sorted(topics.glob("*.md")):
738
- rows, _ = kb_parse_claims(sdlc_core.read_text(p))
739
- for row in rows:
740
- for src in row["source"].split(";"):
741
- name = Path(src.split("#")[0].strip()).name
742
- if name in superseded:
743
- warnings.append(
744
- "topics/%s:%d: claim rests on %s, which a newer "
745
- "version supersedes — re-verify or re-place"
746
- % (p.name, row["_line"], name))
910
+ # claims resting on superseded originals (UC4), from the same single walk
911
+ for name in sorted(superseded):
912
+ for where in (cited.get(name) or {}).get("rows", []):
913
+ warnings.append("%s: claim rests on %s, which a newer version "
914
+ "supersedes re-verify or re-place" % (where, name))
747
915
  return errors, warnings
748
916
 
749
917
 
@@ -355,6 +355,25 @@ owned, validator-read-only): `{ "<task_id>": {"status": "done", "verify_result":
355
355
  sentinel skips re-dispatch; any other value (or a missing `status`) is treated
356
356
  as pending. A ledger id absent from the plan is a non-fatal orphan warning.
357
357
 
358
+ **Ingestion plans: one task per reading window** (`distillation.md` §3). A source too
359
+ long for one context becomes several tasks over the same artifact, each ending at a
360
+ declared page:
361
+
362
+ ```json
363
+ {
364
+ "id": "T2",
365
+ "title": "Extract manual-1a2b3c4d.pdf, pages 31-60",
366
+ "paths": ["ai_docs/topics/pricing.md",
367
+ "ai_docs/corpus/given/manual-1a2b3c4d.pdf.meta.md"],
368
+ "produces": ["ai_docs/corpus/given/manual-1a2b3c4d.pdf.meta.md#extracted_through=p=60"],
369
+ "verify": "python <skill_dir>/scripts/sdlc_check.py check"
370
+ }
371
+ ```
372
+
373
+ That ledger **is** the register an ingestion resumes from across sessions, so ingestion
374
+ adds no second one: what has been covered is recorded (here and on the sidecar), and what
375
+ remains is derived — the next window is the next pending task.
376
+
358
377
  ## ai_docs/audit/audit_plan.md (Standalone mode only)
359
378
 
360
379
  The `Reference` field (git hash or ISO UTC timestamp) is managed by `sdlc_check.py mark` — do not fill it by hand. Freshness is verified with `sdlc_check.py stale`.
@@ -553,6 +572,7 @@ date: 2026-08-01
553
572
  provenance: GIVEN
554
573
  supersedes: contract-1a2b3c4d.pdf
555
574
  extractor: pdftotext 24.02, form-feed page breaks, whitespace collapsed
575
+ extracted_through: p=212
556
576
  ---
557
577
  Handed over by <who>, <context in one line>.
558
578
  ```
@@ -561,6 +581,18 @@ Handed over by <who>, <context in one line>.
561
581
  without it the two content-addressed files are unrelated. `extractor:` pins the stored
562
582
  canonical extraction (`<name>-<hash8>.txt`) that offset locators address.
563
583
 
584
+ `extracted_through:` is how far the source has been read — `p=<n>` (paged extraction),
585
+ `L<n>` (line file), or `complete`. It is **required once any claim cites this artifact**
586
+ and it is advanced at the end of every reading window (`distillation.md` §3): unstated,
587
+ "I am finished" is not falsifiable, which is exactly how a sampled 200-page manual passes
588
+ for an ingested one. Three things are checked: claims with no field (error); a claim
589
+ whose locator addresses **past** the declared coverage, or coverage past the end of the
590
+ stored bytes (error — the sidecar and the rows contradict each other); coverage short of
591
+ the end (warning, because partial work is legal mid-ingestion). Its limit is the mirror
592
+ image of `original_sha256`'s: **nothing proves a page was read**, so a field advanced
593
+ without extracting is caught at the ingestion review, never by the validator. An artifact
594
+ nobody has extracted from yet owes nothing and stays silent.
595
+
564
596
  **Extraction-as-artifact** (`distillation.md` §1 — the variant for a large binary
565
597
  corpus): when the extraction IS the artifact and the original was never copied in, two
566
598
  more fields record where it came from.