@antoneeo/kb-agentic-skill 1.1.1 → 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 +85 -0
- package/README.md +5 -2
- package/gemini-extension.json +1 -1
- package/package.json +2 -1
- package/skills/kb-agentic-skill/SKILL.md +5 -1
- package/skills/kb-agentic-skill/distillation.md +53 -5
- package/skills/kb-agentic-skill/portability.md +125 -0
- package/skills/kb-agentic-skill/reconciliation.md +16 -1
- package/skills/kb-agentic-skill/scripts/sdlc_check.py +454 -21
- package/skills/kb-agentic-skill/templates.md +32 -0
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,22 +12,25 @@ 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
|
|
|
25
27
|
- **Risk-proportional triage, measured in knowledge and never in files**: one claim row → propagating a fact already settled → a new knowledge unit (a source ingested, a node created or superseded, the hierarchy moved). Carrying one settled fact into eight documents is small; one claim that re-parents a node is not. A **Write Triggers** table maps each knowledge event to exactly one destination.
|
|
28
|
+
- **Portable knowledge**: `export` bundles a subgraph together with the bytes its claims cite — a closure, not a selection, because a claim whose source cannot be reopened is model knowledge arriving by another route. `import` merges it into another project **additively**: it never overwrites a node and never deletes, and claims already present are recognised by id rather than by comparing text, since the same artifact cited at the same span mints the same id in every project. Knowledge crosses the project boundary; **authority does not** — an imported ruling arrives as `prov: IMPORTED`, keeps its original `basis:`, and cannot settle a local disagreement until you re-ratify it.
|
|
26
29
|
- **Vision-guided governance**: Standalone projects use `ai_docs/vision/`; Hybrid projects use devPNT `M-VISION` as the milestone north star. `DRAFT` informs, `APPROVED` binds, promotion is the user's alone.
|
|
27
30
|
- **Independent review, twice**: the design before it is implemented, the result before it is declared done — fresh-context subagent > one-shot run > a declared self-pass, 3 rounds max, one log line each, and a PASS is invalid on "found nothing".
|
|
28
31
|
- **Question discipline**: a question is legal only when the agent searched first, names the search with its result, and names the decision it unblocks; otherwise it proceeds on a declared assumption, batched.
|
|
29
32
|
- **Operative guides + agent-global KB**: distil user-provided indications into source-faithful `GUIDE_*.md` (`source_kind: document`) — verbatim snapshot plus hash, so drift is detected mechanically.
|
|
30
|
-
- **Mechanical checks**: `check`, `validate`, `index`, `graph`, `corpus`, `claim-id`, `anchor`, plus the spine's `stale`/`mark`/`gate`/`plan`/`orient`/`migrate`. The graph and corpus checks verify spans against the stored extraction, recompute every id, refuse cycles and unreachable nodes, and enforce `CONTESTED` symmetry.
|
|
33
|
+
- **Mechanical checks**: `check`, `validate`, `index`, `graph`, `corpus`, `claim-id`, `anchor`, `export`, `import`, plus the spine's `stale`/`mark`/`gate`/`plan`/`orient`/`migrate`. The graph and corpus checks verify spans against the stored extraction, recompute every id, refuse cycles and unreachable nodes, and enforce `CONTESTED` symmetry.
|
|
31
34
|
- **Installed support files**: Claude, Codex, Gemini and Google Antigravity receive the full skill folder — `SKILL.md`, `templates.md`, `taxonomy.md`, `distillation.md`, `reconciliation.md`, `guides.md`, `vision.md`, `elicitation.md`, `review.md`, `dispatch.md`, `routing.md`, `ENFORCEMENT.md`, and the validator's two files, `scripts/sdlc_check.py` + `scripts/sdlc_core.py` (the core is the family's shared spine — copy both, or neither).
|
|
32
35
|
|
|
33
36
|
## Installation
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kb-agentic-skill",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"skills/kb-agentic-skill/review.md",
|
|
37
37
|
"skills/kb-agentic-skill/dispatch.md",
|
|
38
38
|
"skills/kb-agentic-skill/routing.md",
|
|
39
|
+
"skills/kb-agentic-skill/portability.md",
|
|
39
40
|
"skills/kb-agentic-skill/ENFORCEMENT.md",
|
|
40
41
|
"skills/kb-agentic-skill/scripts/sdlc_check.py",
|
|
41
42
|
"skills/kb-agentic-skill/scripts/sdlc_core.py",
|
|
@@ -13,6 +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
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.
|
|
17
18
|
- `guides.md`: pipeline for distilling user-provided indications into `ai_docs/reference/GUIDE_[topic].md`.
|
|
18
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.
|
|
@@ -21,7 +22,7 @@ Support files in the skill directory:
|
|
|
21
22
|
- `review.md`: the review discipline — when a review is due, how to request one, how to receive findings, how to review.
|
|
22
23
|
- `dispatch.md`: opt-in subagent execution of an approved plan.
|
|
23
24
|
- `routing.md`: which lens owns this unit of work. Read ONLY when a sibling lens skill is installed alongside this one; a single-lens install never reads it.
|
|
24
|
-
- `scripts/sdlc_check.py` + `scripts/sdlc_core.py`: the mechanical validator for the docs root (`check`, `validate`, `index`, `stale`, `mark`, `gate`, `plan`, `orient`, `migrate`, and the knowledge overlay: `graph`, `corpus`, `claim-id`, `anchor`). Two files: the core is the family's shared spine; the entry point IS the knowledge overlay — the claim ledger and topic-graph checks live inside it, so the core alone runs none of them. Copy both, or neither.
|
|
25
|
+
- `scripts/sdlc_check.py` + `scripts/sdlc_core.py`: the mechanical validator for the docs root (`check`, `validate`, `index`, `stale`, `mark`, `gate`, `plan`, `orient`, `migrate`, and the knowledge overlay: `graph`, `corpus`, `claim-id`, `anchor`, `export`, `import`). Two files: the core is the family's shared spine; the entry point IS the knowledge overlay — the claim ledger and topic-graph checks live inside it, so the core alone runs none of them. Copy both, or neither.
|
|
25
26
|
- `ENFORCEMENT.md`: optional setup for CI and hooks.
|
|
26
27
|
|
|
27
28
|
Read these files only when needed. `SKILL.md` is the operating contract; the support files are progressive resources.
|
|
@@ -83,8 +84,11 @@ Triage decides IF documentation is due; this table decides WHICH document each e
|
|
|
83
84
|
| `strategic/architecture.md`, `strategic/existing_features.md` | Bootstrap; update at closure when the knowledge catalog actually changed. | 1 / 5 |
|
|
84
85
|
| `vision/project_vision.md`, `roadmap.md`, `principles.md` | Bootstrap as `Status: DRAFT`; promoted to APPROVED only by explicit user confirmation. | 1 / 2 |
|
|
85
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`). | — |
|
|
86
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 |
|
|
87
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 |
|
|
88
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 |
|
|
89
93
|
|
|
90
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,
|
|
23
|
-
`supersedes:` when it replaces an earlier version
|
|
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
|
|
@@ -69,7 +83,7 @@ Claims live in the owning topic's `## Claims` table (`templates.md` has the temp
|
|
|
69
83
|
`p=<n>@<start>-<end>` (character offsets into page n of the stored extraction),
|
|
70
84
|
`L<a>-<b>` (line files), `Sheet<s>!<cell>`. The span must exist — the validator opens
|
|
71
85
|
the file and checks.
|
|
72
|
-
- **prov** — `GIVEN | ELICITED | DERIVED | RULING`. Information for whoever resolves a
|
|
86
|
+
- **prov** — `GIVEN | ELICITED | DERIVED | RULING | IMPORTED`. `IMPORTED` is a ruling that came from another project (`portability.md`): it keeps its text and original `basis:`, its note must say `imported_from:`, and it may not supersede a local row until you re-ratify it. Information for whoever resolves a
|
|
73
87
|
conflict; never a rank.
|
|
74
88
|
- **state** — `OK`, `CONTESTED <ids>`, `SUPERSEDED <id>` (`reconciliation.md` owns the
|
|
75
89
|
transitions).
|
|
@@ -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
|
|
114
|
-
|
|
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).
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Portability — carrying knowledge between projects
|
|
2
|
+
|
|
3
|
+
**For whom**: the agent moving a subgraph out of one project or into another.
|
|
4
|
+
**Answers**: "what travels, what refuses to travel, and what an import may decide".
|
|
5
|
+
**Does not answer**: where a claim's concept belongs (`taxonomy.md` — import *uses* that
|
|
6
|
+
pass, it does not replace it) or how a disagreement is settled (`reconciliation.md`).
|
|
7
|
+
|
|
8
|
+
## 0. The two commands, and what to tell the user
|
|
9
|
+
|
|
10
|
+
A bundle is a **folder**, not an archive. Nothing here compresses or unpacks anything:
|
|
11
|
+
the user moves the folder however they already move folders.
|
|
12
|
+
|
|
13
|
+
**Sending side** — run from the project that owns the knowledge:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
sdlc_check.py export --out ../kb-bundle # the whole KB
|
|
17
|
+
sdlc_check.py export --out ../kb-bundle --topics pricing,licensing
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Write it **outside the docs root**. A bundle sitting inside `ai_docs/` is a second copy
|
|
21
|
+
of topics and corpus files in the tree the validator walks, and that confusion is free
|
|
22
|
+
to avoid.
|
|
23
|
+
|
|
24
|
+
**Receiving side** — run from the project that is to gain it:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
sdlc_check.py import ../kb-bundle --dry-run # see what would land
|
|
28
|
+
sdlc_check.py import ../kb-bundle
|
|
29
|
+
sdlc_check.py check # always, right after
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**What to say to the user, in their words.** Say these four things and stop:
|
|
33
|
+
|
|
34
|
+
1. *"I've written the bundle to `<path>`. It's a folder — move it to the other project
|
|
35
|
+
however you like."*
|
|
36
|
+
2. *"It carries the source documents too, not just the notes, so it is not small. That is
|
|
37
|
+
the point: over there the claims can still be traced back to the page they came from."*
|
|
38
|
+
3. After importing: *"Nothing was overwritten. Anything already there stayed as it was"* —
|
|
39
|
+
and, if a topic was skipped, *"a topic with the same name already exists; I have not
|
|
40
|
+
merged them, because they may not be the same subject. Do you want me to look?"*
|
|
41
|
+
4. If an imported ruling arrived: *"one decision came from the other project. I have kept
|
|
42
|
+
it, marked as theirs, and it cannot settle anything here until you confirm it."*
|
|
43
|
+
|
|
44
|
+
Do not explain the closure, the id hashing or the provenance model unless asked. The
|
|
45
|
+
user asked to move their knowledge, not to learn how it is stored.
|
|
46
|
+
|
|
47
|
+
## 1. What a bundle is
|
|
48
|
+
|
|
49
|
+
A directory mirroring the docs-root layout:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
<bundle>/
|
|
53
|
+
MANIFEST.md kb_bundle: 1, source_project, topics, per-artifact sha256
|
|
54
|
+
topics/<slug>.md
|
|
55
|
+
corpus/given/<name> + <name>.meta.md (+ the .txt extraction, when there is one)
|
|
56
|
+
corpus/notes/<name>.md
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The layout is mirrored **on purpose**. Claim `source` cells are docs-root-relative, so
|
|
60
|
+
nothing is rewritten on import — and because `kb_claim_id` hashes
|
|
61
|
+
`path#locator#qty` with the text excluded, the same artifact cited at the same span
|
|
62
|
+
mints **the same id in every project**. De-duplication is therefore mechanical, not a
|
|
63
|
+
judgement call, and importing the same bundle twice is a provable no-op.
|
|
64
|
+
|
|
65
|
+
## 2. Export is a closure, not a selection
|
|
66
|
+
|
|
67
|
+
You choose topics; the export decides what must travel with them.
|
|
68
|
+
|
|
69
|
+
- **Every artifact a selected claim cites**, plus its sidecar and its stored extraction.
|
|
70
|
+
A claim whose source cannot be reopened is model knowledge arriving by another route —
|
|
71
|
+
the validator would reject it in the target, and correctly.
|
|
72
|
+
- **Every row a `CONTESTED` row points at.** The symmetry check refuses a set that lost
|
|
73
|
+
half its members, so a partial export ships a tree that cannot pass its own checks.
|
|
74
|
+
When a partner row lives in an unselected topic, that topic is **added and reported** —
|
|
75
|
+
never dropped, never silently.
|
|
76
|
+
|
|
77
|
+
If a conflict partner resolves to no row at all, the export **refuses**: exporting a
|
|
78
|
+
broken set is worse than exporting nothing.
|
|
79
|
+
|
|
80
|
+
## 3. Import is additive, and decides nothing
|
|
81
|
+
|
|
82
|
+
`import` writes files. It does not place concepts, merge bodies, or settle disagreements.
|
|
83
|
+
|
|
84
|
+
- **Never overwrites a topic.** A slug that already exists is reported and skipped: two
|
|
85
|
+
projects using the word `pricing` may mean two different things, and that judgement is
|
|
86
|
+
the placement pass's (`taxonomy.md`, five verdicts, `owns:` against double placement).
|
|
87
|
+
- **Never deletes.** The doctrine is tombstones over deletion; an additive import has no
|
|
88
|
+
business removing anything.
|
|
89
|
+
- **All or nothing.** The whole plan is computed before a byte is written. An import that
|
|
90
|
+
half-applies leaves a tree whose checks fail and whose owner cannot tell what landed.
|
|
91
|
+
|
|
92
|
+
It refuses on: a missing or unmarked `MANIFEST.md`; a path that escapes the docs root; an
|
|
93
|
+
artifact whose name matches an existing one **with different bytes** (content-addressed
|
|
94
|
+
names must mean equal content — a mismatch means one of the two is lying about its
|
|
95
|
+
origin); an incomplete conflict set; a dangling `supersedes:`.
|
|
96
|
+
|
|
97
|
+
After importing, run `check`. The import is deliberately not a validator.
|
|
98
|
+
|
|
99
|
+
## 4. Knowledge crosses; authority does not
|
|
100
|
+
|
|
101
|
+
`RULING` means *the fact you know and the corpus does not*, with a `basis:` you gave. It
|
|
102
|
+
is the only thing that settles a `CONTESTED` set. A ruling from another project carries
|
|
103
|
+
another owner's decision, and importing it unchanged would make that decision binding
|
|
104
|
+
here without anyone here granting it — the machine deciding, which reconciliation
|
|
105
|
+
refuses everywhere else.
|
|
106
|
+
|
|
107
|
+
So an imported ruling arrives as **`prov: IMPORTED`** (owner ruling, 2026-08-03):
|
|
108
|
+
|
|
109
|
+
- its text, span and original `basis:` travel **verbatim** — the knowledge is not lost,
|
|
110
|
+
and pretending it is a `DERIVED` synthesis would make the row lie about where it came
|
|
111
|
+
from;
|
|
112
|
+
- its note carries `imported_from:`, and the validator refuses an `IMPORTED` row without
|
|
113
|
+
it: unnamed, the class says nothing and the row is a `RULING` with the label filed off;
|
|
114
|
+
- **it cannot supersede a local row.** The validator errors on `SUPERSEDED <id>` when
|
|
115
|
+
that id is `IMPORTED`.
|
|
116
|
+
|
|
117
|
+
**Re-ratification** is one act: read it, write your own note with your own `basis:`, and
|
|
118
|
+
set the row's `prov` to `RULING`. That is you deciding, which is the only thing that ever
|
|
119
|
+
settles anything here.
|
|
120
|
+
|
|
121
|
+
## 5. Triage
|
|
122
|
+
|
|
123
|
+
Exporting is L1 — it writes nothing into the corpus and changes no knowledge. Importing
|
|
124
|
+
is **L3**: a source enters the corpus and nodes are created, which is the L3 definition
|
|
125
|
+
in Rule Zero, and the bundle is external input, which is never L1 whatever its size.
|
|
@@ -76,4 +76,19 @@ Whole documents keep the family lifecycle: a superseding note or guide marks the
|
|
|
76
76
|
`status: SUPERSEDED`; `supersedes:` in the new one's frontmatter links them. Claims and
|
|
77
77
|
documents move independently — superseding a document does not silently resolve the
|
|
78
78
|
claims extracted from it; the `corpus` check reports claims resting on superseded
|
|
79
|
-
originals for re-verification.
|
|
79
|
+
originals for re-verification.
|
|
80
|
+
|
|
81
|
+
## Rulings that came from another project
|
|
82
|
+
|
|
83
|
+
An `IMPORTED` row is a ruling made by the owner of a different project
|
|
84
|
+
(`portability.md`). Knowledge crosses a project boundary; authority does not.
|
|
85
|
+
|
|
86
|
+
- It keeps its text, its span and its original `basis:` verbatim — the knowledge is not
|
|
87
|
+
lost, and relabelling it `DERIVED` would make the row lie about its origin.
|
|
88
|
+
- Its note must carry `imported_from:`. Unnamed, the class says nothing and the row is a
|
|
89
|
+
`RULING` with the label filed off; the validator refuses it.
|
|
90
|
+
- **It cannot settle anything here.** `SUPERSEDED <id>` pointing at an `IMPORTED` row is
|
|
91
|
+
an error: nobody in this project granted that decision its authority.
|
|
92
|
+
|
|
93
|
+
Re-ratification is one act, and it is you deciding: read it, write your own note with
|
|
94
|
+
your own `basis:`, set `prov` to `RULING`.
|
|
@@ -62,12 +62,17 @@ sdlc_core.set_profile(
|
|
|
62
62
|
unit_noun="topic",
|
|
63
63
|
support_files=("templates.md", "taxonomy.md", "guides.md", "vision.md",
|
|
64
64
|
"distillation.md", "reconciliation.md", "elicitation.md",
|
|
65
|
-
"review.md", "dispatch.md", "routing.md", "
|
|
65
|
+
"review.md", "dispatch.md", "routing.md", "portability.md",
|
|
66
|
+
"ENFORCEMENT.md"),
|
|
66
67
|
capabilities=(
|
|
67
68
|
# spine
|
|
68
69
|
"triage", "write_triggers", "workstream_registry", "vision_gate",
|
|
69
70
|
"design_review_gate", "guide_router", "worktree_hygiene",
|
|
70
71
|
# knowledge overlay
|
|
72
|
+
# `knowledge_portability` (F-030) is deliberately NOT declared: the
|
|
73
|
+
# capability vocabulary lives in the shared spine, and no shared test
|
|
74
|
+
# guards on portability, so adding a label there would mean editing
|
|
75
|
+
# sdlc_core.py in three distributions to buy nothing.
|
|
71
76
|
"taxonomy_pass", "subagent_dispatch", "question_discipline",
|
|
72
77
|
),
|
|
73
78
|
design_gate_between=("### 3. Request Analysis & Taxonomy Pass",
|
|
@@ -80,13 +85,15 @@ sdlc_core.set_profile(
|
|
|
80
85
|
|
|
81
86
|
CLAIM_COLUMNS = ("id", "claim", "valid", "qty", "about", "source", "prov", "state")
|
|
82
87
|
CLAIM_HEADING = "## Claims"
|
|
83
|
-
PROVENANCES = ("GIVEN", "ELICITED", "DERIVED", "RULING")
|
|
88
|
+
PROVENANCES = ("GIVEN", "ELICITED", "DERIVED", "RULING", "IMPORTED")
|
|
84
89
|
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
85
90
|
OWNS_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}/[a-z0-9][a-z0-9-]{0,63}$")
|
|
86
91
|
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
87
92
|
LOC_PAGE_RE = re.compile(r"^p=(\d+)@(\d+)-(\d+)$")
|
|
88
93
|
LOC_LINE_RE = re.compile(r"^L(\d+)-(\d+)$")
|
|
89
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+))$")
|
|
90
97
|
|
|
91
98
|
# Unit conventions, documented in templates.md. effort in person-days
|
|
92
99
|
# (8h day, 5d week, 21d month); duration in calendar days; cost within ONE
|
|
@@ -315,7 +322,7 @@ def kb_check_claims(root):
|
|
|
315
322
|
prov = row["prov"]
|
|
316
323
|
if prov not in PROVENANCES:
|
|
317
324
|
errors.append("%s: prov %r not in %s" % (where, prov, "/".join(PROVENANCES)))
|
|
318
|
-
elif prov in ("DERIVED", "RULING", "ELICITED"):
|
|
325
|
+
elif prov in ("DERIVED", "RULING", "ELICITED", "IMPORTED"):
|
|
319
326
|
meta = _note_frontmatter(root, first.rsplit("#", 1)[0])
|
|
320
327
|
if meta is None:
|
|
321
328
|
pass # unresolvable source already reported
|
|
@@ -325,6 +332,13 @@ def kb_check_claims(root):
|
|
|
325
332
|
elif prov == "RULING" and not meta.get("basis"):
|
|
326
333
|
errors.append("%s: RULING note carries no 'basis:' — a preference "
|
|
327
334
|
"is not a fact; no basis, no ruling" % where)
|
|
335
|
+
elif prov == "IMPORTED" and not meta.get("imported_from"):
|
|
336
|
+
# F-030: IMPORTED exists so a foreign decision cannot pass for
|
|
337
|
+
# a local one. Without the origin the class says nothing and
|
|
338
|
+
# 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)
|
|
328
342
|
# --- grammar cells ---
|
|
329
343
|
try:
|
|
330
344
|
kb_parse_scope(row["valid"])
|
|
@@ -384,6 +398,18 @@ def kb_check_claims(root):
|
|
|
384
398
|
"the check, it does not clean up" % (where, kind, t))
|
|
385
399
|
continue
|
|
386
400
|
orow, _ = other
|
|
401
|
+
if kind == "SUPERSEDED" and orow["prov"] == "IMPORTED":
|
|
402
|
+
# F-030, owner ruling 2026-08-03: knowledge crosses a project
|
|
403
|
+
# boundary, authority does not. An IMPORTED row carries another
|
|
404
|
+
# owner's decision; letting it supersede a local row would make
|
|
405
|
+
# that decision binding here without anyone here granting it.
|
|
406
|
+
# Re-ratify first: write your own ruling note with your own
|
|
407
|
+
# basis and flip the row to RULING.
|
|
408
|
+
errors.append("%s: SUPERSEDED by %s, which is IMPORTED — a "
|
|
409
|
+
"foreign decision cannot settle a local row. "
|
|
410
|
+
"Re-ratify it (own note, own 'basis:', prov "
|
|
411
|
+
"RULING) or resolve this some other way"
|
|
412
|
+
% (where, t))
|
|
387
413
|
if kind == "CONTESTED":
|
|
388
414
|
if orow["state"].startswith("SUPERSEDED"):
|
|
389
415
|
errors.append("%s: CONTESTED points at SUPERSEDED row %s — "
|
|
@@ -640,6 +666,64 @@ def kb_build_topic_index(root):
|
|
|
640
666
|
return "\n".join(lines) + "\n"
|
|
641
667
|
|
|
642
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
|
+
|
|
643
727
|
def kb_build_corpus_index(root):
|
|
644
728
|
"""One row per corpus artifact, from sidecars and note frontmatter."""
|
|
645
729
|
corpus = root / "corpus"
|
|
@@ -652,9 +736,10 @@ def kb_build_corpus_index(root):
|
|
|
652
736
|
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
|
|
653
737
|
orig = meta_p.name[:-len(".meta.md")]
|
|
654
738
|
sup = (meta.get("supersedes") or "").strip()
|
|
655
|
-
lines.append("- `%s` — %s%s" % (
|
|
739
|
+
lines.append("- `%s` — %s%s — %s" % (
|
|
656
740
|
orig, (meta.get("date") or "undated"),
|
|
657
|
-
(" — supersedes `%s`" % sup) if sup else ""
|
|
741
|
+
(" — supersedes `%s`" % sup) if sup else "",
|
|
742
|
+
kb_coverage_cell(given / orig, meta.get("extracted_through"))))
|
|
658
743
|
notes = corpus / "notes"
|
|
659
744
|
if notes.is_dir():
|
|
660
745
|
lines.append("")
|
|
@@ -668,12 +753,125 @@ def kb_build_corpus_index(root):
|
|
|
668
753
|
return "\n".join(lines) + "\n"
|
|
669
754
|
|
|
670
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
|
+
|
|
671
867
|
def kb_corpus_check(root):
|
|
672
|
-
"""Corpus integrity: digests, supersession, laundered notes.
|
|
868
|
+
"""Corpus integrity: digests, supersession, coverage, laundered notes.
|
|
869
|
+
Findings only."""
|
|
673
870
|
errors, warnings = [], []
|
|
674
871
|
corpus = root / "corpus"
|
|
675
872
|
if not corpus.is_dir():
|
|
676
873
|
return errors, warnings
|
|
874
|
+
cited = kb_cited_extents(root)
|
|
677
875
|
superseded = set()
|
|
678
876
|
given = corpus / "given"
|
|
679
877
|
if given.is_dir():
|
|
@@ -697,6 +895,9 @@ def kb_corpus_check(root):
|
|
|
697
895
|
if not (given / sup).is_file():
|
|
698
896
|
warnings.append("%s: supersedes %r which is not in given/"
|
|
699
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)
|
|
700
901
|
notes = corpus / "notes"
|
|
701
902
|
if notes.is_dir():
|
|
702
903
|
for p in sorted(notes.glob("*.md")):
|
|
@@ -706,20 +907,11 @@ def kb_corpus_check(root):
|
|
|
706
907
|
errors.append("corpus/notes/%s: neither 'derived_from:' nor "
|
|
707
908
|
"'origin:' nor 'basis:' — model knowledge disguised "
|
|
708
909
|
"as a source" % p.name)
|
|
709
|
-
# claims resting on superseded originals (UC4)
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
rows, _ = kb_parse_claims(sdlc_core.read_text(p))
|
|
715
|
-
for row in rows:
|
|
716
|
-
for src in row["source"].split(";"):
|
|
717
|
-
name = Path(src.split("#")[0].strip()).name
|
|
718
|
-
if name in superseded:
|
|
719
|
-
warnings.append(
|
|
720
|
-
"topics/%s:%d: claim rests on %s, which a newer "
|
|
721
|
-
"version supersedes — re-verify or re-place"
|
|
722
|
-
% (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))
|
|
723
915
|
return errors, warnings
|
|
724
916
|
|
|
725
917
|
|
|
@@ -734,10 +926,179 @@ def kb_sha256_bytes(path):
|
|
|
734
926
|
return h.hexdigest()
|
|
735
927
|
|
|
736
928
|
|
|
929
|
+
# ------------------------------------------------------- portability (F-030)
|
|
930
|
+
# Export a subgraph WITH the bytes its claims cite; import it additively.
|
|
931
|
+
#
|
|
932
|
+
# The bundle mirrors the docs-root layout on purpose: claim `source` cells are
|
|
933
|
+
# docs-root-relative, so nothing is rewritten on import and `kb_claim_id` --
|
|
934
|
+
# sha256(path#locator#qty), text excluded -- mints the SAME id in both projects.
|
|
935
|
+
# That is what makes de-duplication mechanical instead of a judgement call, and
|
|
936
|
+
# it is why this feature is small.
|
|
937
|
+
|
|
938
|
+
BUNDLE_MANIFEST = "MANIFEST.md"
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def kb_claim_sources(row):
|
|
942
|
+
"""The docs-root-relative artifact paths a claim row cites (no locators)."""
|
|
943
|
+
out = []
|
|
944
|
+
for src in row["source"].split(";"):
|
|
945
|
+
src = src.strip()
|
|
946
|
+
if src and "#" in src:
|
|
947
|
+
out.append(src.rsplit("#", 1)[0])
|
|
948
|
+
elif src:
|
|
949
|
+
out.append(src)
|
|
950
|
+
return out
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def kb_collect_topics(docs):
|
|
954
|
+
"""{slug: (path, text, rows)} for every topic node that parses."""
|
|
955
|
+
out = {}
|
|
956
|
+
tdir = docs / "topics"
|
|
957
|
+
if not tdir.is_dir():
|
|
958
|
+
return out
|
|
959
|
+
for p in sorted(tdir.glob("*.md")):
|
|
960
|
+
if p.name == "INDEX.md":
|
|
961
|
+
continue
|
|
962
|
+
text = sdlc_core.read_text(p)
|
|
963
|
+
meta = sdlc_core.load_frontmatter(text.splitlines()) or {}
|
|
964
|
+
rows, _ = kb_parse_claims(text)
|
|
965
|
+
out[(meta.get("topic") or p.stem).strip()] = (p, text, rows)
|
|
966
|
+
return out
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def kb_export_closure(docs, slugs):
|
|
970
|
+
"""(topics, artifacts, added_for_conflicts, errors).
|
|
971
|
+
|
|
972
|
+
Closure in two directions, because a partial export produces a target whose
|
|
973
|
+
own checks fail:
|
|
974
|
+
* every artifact a selected claim cites travels with it -- a claim whose
|
|
975
|
+
source cannot be reopened is model knowledge arriving by another route;
|
|
976
|
+
* every row a CONTESTED row points at travels too, since the symmetry
|
|
977
|
+
check refuses a set that lost half its members. When such a row lives in
|
|
978
|
+
an unselected topic, that topic is ADDED and reported, never dropped.
|
|
979
|
+
"""
|
|
980
|
+
all_topics = kb_collect_topics(docs)
|
|
981
|
+
errors = []
|
|
982
|
+
for s in slugs:
|
|
983
|
+
if s not in all_topics:
|
|
984
|
+
errors.append("no such topic: %s" % s)
|
|
985
|
+
if errors:
|
|
986
|
+
return {}, [], [], errors
|
|
987
|
+
selected = dict((s, all_topics[s]) for s in slugs)
|
|
988
|
+
# id -> slug, over the WHOLE graph, so a conflict partner is findable
|
|
989
|
+
owner_of = {}
|
|
990
|
+
for slug, (_p, _t, rows) in all_topics.items():
|
|
991
|
+
for r in rows:
|
|
992
|
+
if r["id"]:
|
|
993
|
+
owner_of[r["id"]] = slug
|
|
994
|
+
added = []
|
|
995
|
+
pending = list(selected)
|
|
996
|
+
while pending:
|
|
997
|
+
slug = pending.pop()
|
|
998
|
+
for r in selected[slug][2]:
|
|
999
|
+
m = re.match(r"^(CONTESTED|SUPERSEDED) ([0-9a-f, ]+)$", r["state"].strip())
|
|
1000
|
+
if not m:
|
|
1001
|
+
continue
|
|
1002
|
+
for ref in [x.strip() for x in m.group(2).split(",") if x.strip()]:
|
|
1003
|
+
other = owner_of.get(ref)
|
|
1004
|
+
if other is None:
|
|
1005
|
+
errors.append("claim %s in topic '%s' points at id %s, which "
|
|
1006
|
+
"no topic owns: export would carry a broken set"
|
|
1007
|
+
% (r["id"] or "(no id)", slug, ref))
|
|
1008
|
+
elif other not in selected:
|
|
1009
|
+
selected[other] = all_topics[other]
|
|
1010
|
+
added.append(other)
|
|
1011
|
+
pending.append(other)
|
|
1012
|
+
artifacts = []
|
|
1013
|
+
seen = set()
|
|
1014
|
+
for slug, (_p, _t, rows) in sorted(selected.items()):
|
|
1015
|
+
for r in rows:
|
|
1016
|
+
for rel in kb_claim_sources(r):
|
|
1017
|
+
for cand in (rel, rel + ".meta.md",
|
|
1018
|
+
rel[:-len(Path(rel).suffix)] + ".txt" if Path(rel).suffix else rel):
|
|
1019
|
+
if cand in seen:
|
|
1020
|
+
continue
|
|
1021
|
+
if (docs / cand).is_file():
|
|
1022
|
+
seen.add(cand)
|
|
1023
|
+
artifacts.append(cand)
|
|
1024
|
+
return selected, artifacts, added, errors
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def kb_bundle_write(docs, out, selected, artifacts, project):
|
|
1028
|
+
"""Write the bundle. Mirrors the docs-root layout; no path is rewritten."""
|
|
1029
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
1030
|
+
(out / "topics").mkdir(exist_ok=True)
|
|
1031
|
+
for slug, (p, text, _rows) in sorted(selected.items()):
|
|
1032
|
+
(out / "topics" / p.name).write_text(text, encoding="utf-8")
|
|
1033
|
+
for rel in artifacts:
|
|
1034
|
+
dst = out / rel
|
|
1035
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1036
|
+
dst.write_bytes((docs / rel).read_bytes())
|
|
1037
|
+
lines = ["---", "kb_bundle: 1", "source_project: %s" % project,
|
|
1038
|
+
"topics: [%s]" % ", ".join(sorted(selected)),
|
|
1039
|
+
"artifacts: %d" % len(artifacts), "---",
|
|
1040
|
+
"# KB bundle", "",
|
|
1041
|
+
"Import with `sdlc_check.py import <this directory>`. Additive: it "
|
|
1042
|
+
"never overwrites a node and never deletes anything.", ""]
|
|
1043
|
+
for rel in artifacts:
|
|
1044
|
+
lines.append("- `%s` sha256:%s" % (rel, kb_sha256_bytes(docs / rel)))
|
|
1045
|
+
(out / BUNDLE_MANIFEST).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def kb_import_plan(bundle, docs):
|
|
1049
|
+
"""(writes, skipped_topics, dedup, errors) -- computed BEFORE anything is
|
|
1050
|
+
written, because an import that half-applies leaves a tree whose checks fail
|
|
1051
|
+
and whose owner cannot tell what landed."""
|
|
1052
|
+
errors, writes, skipped, dedup = [], [], [], []
|
|
1053
|
+
man = bundle / BUNDLE_MANIFEST
|
|
1054
|
+
if not man.is_file():
|
|
1055
|
+
return [], [], [], ["not a kb bundle: no %s in %s" % (BUNDLE_MANIFEST, bundle)]
|
|
1056
|
+
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(man).splitlines()) or {}
|
|
1057
|
+
if str(meta.get("kb_bundle", "")).strip() != "1":
|
|
1058
|
+
return [], [], [], ["%s carries no 'kb_bundle: 1'" % BUNDLE_MANIFEST]
|
|
1059
|
+
|
|
1060
|
+
known_ids = set()
|
|
1061
|
+
target = kb_collect_topics(docs)
|
|
1062
|
+
for _slug, (_p, _t, rows) in target.items():
|
|
1063
|
+
for r in rows:
|
|
1064
|
+
if r["id"]:
|
|
1065
|
+
known_ids.add(r["id"])
|
|
1066
|
+
|
|
1067
|
+
for p in sorted(bundle.rglob("*")):
|
|
1068
|
+
if not p.is_file() or p.name == BUNDLE_MANIFEST:
|
|
1069
|
+
continue
|
|
1070
|
+
rel = p.relative_to(bundle).as_posix()
|
|
1071
|
+
dst = sdlc_core.confine_under(docs, rel)
|
|
1072
|
+
if dst is None:
|
|
1073
|
+
errors.append("bundle entry %r escapes the docs root — refusing the "
|
|
1074
|
+
"whole import, not just this file" % rel)
|
|
1075
|
+
continue
|
|
1076
|
+
if dst.is_file() and not rel.startswith("topics/"):
|
|
1077
|
+
if kb_sha256_bytes(dst) != kb_sha256_bytes(p):
|
|
1078
|
+
errors.append("%s exists with different bytes (target %s… vs "
|
|
1079
|
+
"bundle %s…): content-addressed names must mean "
|
|
1080
|
+
"equal content" % (rel, kb_sha256_bytes(dst)[:8],
|
|
1081
|
+
kb_sha256_bytes(p)[:8]))
|
|
1082
|
+
continue
|
|
1083
|
+
if rel.startswith("topics/") and dst.is_file():
|
|
1084
|
+
skipped.append(rel)
|
|
1085
|
+
continue
|
|
1086
|
+
writes.append((rel, p, dst))
|
|
1087
|
+
|
|
1088
|
+
for rel, p, _dst in writes:
|
|
1089
|
+
if not rel.startswith("topics/"):
|
|
1090
|
+
continue
|
|
1091
|
+
rows, _ = kb_parse_claims(sdlc_core.read_text(p))
|
|
1092
|
+
for r in rows:
|
|
1093
|
+
if r["id"] and r["id"] in known_ids:
|
|
1094
|
+
dedup.append(r["id"])
|
|
1095
|
+
return writes, skipped, dedup, errors
|
|
1096
|
+
|
|
1097
|
+
|
|
737
1098
|
# ------------------------------------------------------------------ commands
|
|
738
1099
|
|
|
739
1100
|
INTERCEPTED = {"index", "validate", "check", "graph", "corpus", "claim-id",
|
|
740
|
-
"anchor"}
|
|
1101
|
+
"anchor", "export", "import"}
|
|
741
1102
|
|
|
742
1103
|
|
|
743
1104
|
def _kb_root(args):
|
|
@@ -900,6 +1261,62 @@ def kb_cmd_anchor(args):
|
|
|
900
1261
|
return 0
|
|
901
1262
|
|
|
902
1263
|
|
|
1264
|
+
def kb_cmd_export(args):
|
|
1265
|
+
root, docs = _kb_root(args)
|
|
1266
|
+
slugs = ([s.strip() for s in args.topics.split(",") if s.strip()]
|
|
1267
|
+
if args.topics else sorted(kb_collect_topics(docs)))
|
|
1268
|
+
if not slugs:
|
|
1269
|
+
print("[ERROR] no topics to export")
|
|
1270
|
+
return 2
|
|
1271
|
+
selected, artifacts, added, errors = kb_export_closure(docs, slugs)
|
|
1272
|
+
for e in errors:
|
|
1273
|
+
print("[ERROR] %s" % e)
|
|
1274
|
+
if errors:
|
|
1275
|
+
return 1
|
|
1276
|
+
out = Path(args.out)
|
|
1277
|
+
kb_bundle_write(docs, out, selected, artifacts, root.name)
|
|
1278
|
+
print("[ok] bundle written: %s" % out)
|
|
1279
|
+
print(" topics: %d, artifacts: %d" % (len(selected), len(artifacts)))
|
|
1280
|
+
if added:
|
|
1281
|
+
# never silent: a set that grew is a fact about the export, and the
|
|
1282
|
+
# alternative -- dropping the partner rows -- ships a broken tree.
|
|
1283
|
+
print(" +%d topic(s) added to keep conflict sets whole: %s"
|
|
1284
|
+
% (len(added), ", ".join(sorted(added))))
|
|
1285
|
+
return 0
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def kb_cmd_import(args):
|
|
1289
|
+
_root, docs = _kb_root(args)
|
|
1290
|
+
bundle = Path(args.bundle)
|
|
1291
|
+
if not bundle.is_dir():
|
|
1292
|
+
print("[ERROR] no such bundle directory: %s" % bundle)
|
|
1293
|
+
return 2
|
|
1294
|
+
writes, skipped, dedup, errors = kb_import_plan(bundle, docs)
|
|
1295
|
+
for e in errors:
|
|
1296
|
+
print("[ERROR] %s" % e)
|
|
1297
|
+
if errors:
|
|
1298
|
+
print("[ERROR] nothing was written: an import that half-applies leaves a "
|
|
1299
|
+
"tree whose checks fail and whose owner cannot tell what landed.")
|
|
1300
|
+
return 1
|
|
1301
|
+
if args.dry_run:
|
|
1302
|
+
print("[ok] dry run: %d file(s) would be written" % len(writes))
|
|
1303
|
+
else:
|
|
1304
|
+
for _rel, src, dst in writes:
|
|
1305
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1306
|
+
dst.write_bytes(src.read_bytes())
|
|
1307
|
+
print("[ok] imported %d file(s) into %s" % (len(writes), docs))
|
|
1308
|
+
if dedup:
|
|
1309
|
+
print(" %d claim(s) already present, by id — the same artifact cited "
|
|
1310
|
+
"at the same span mints the same id in any project" % len(dedup))
|
|
1311
|
+
for rel in skipped:
|
|
1312
|
+
print("[note] %s already exists: NOT overwritten. Run the placement pass "
|
|
1313
|
+
"(taxonomy.md) and merge by hand — an import never decides that."
|
|
1314
|
+
% rel)
|
|
1315
|
+
print("[note] re-run 'sdlc_check.py check' now: the import is additive, and "
|
|
1316
|
+
"an imported RULING stays IMPORTED until you re-ratify it.")
|
|
1317
|
+
return 0
|
|
1318
|
+
|
|
1319
|
+
|
|
903
1320
|
def kb_cmd_help():
|
|
904
1321
|
"""The spine's usage, then the overlay's own commands.
|
|
905
1322
|
|
|
@@ -919,6 +1336,8 @@ knowledge overlay (kb-agentic) -- also available:
|
|
|
919
1336
|
corpus corpus integrity: digests, supersession, notes
|
|
920
1337
|
claim-id <path> <locator> compute a claim id (--fill to fill a whole table)
|
|
921
1338
|
anchor <path> <phrase> resolve a phrase to a verified locator span
|
|
1339
|
+
export --out <dir> bundle a subgraph WITH the bytes its claims cite
|
|
1340
|
+
import <dir> merge a bundle in additively (never overwrites)
|
|
922
1341
|
|
|
923
1342
|
index / validate / check the spine's behaviour PLUS the claim ledger and
|
|
924
1343
|
the topic graph""")
|
|
@@ -962,11 +1381,25 @@ def main(argv=None):
|
|
|
962
1381
|
p.add_argument("--page", type=int)
|
|
963
1382
|
p.add_argument("--ignore-case", action="store_true")
|
|
964
1383
|
p.add_argument("--all", action="store_true")
|
|
1384
|
+
p = sub.add_parser("export")
|
|
1385
|
+
p.add_argument("--out", required=True)
|
|
1386
|
+
p.add_argument("--topics")
|
|
1387
|
+
p.add_argument("--root")
|
|
1388
|
+
p.add_argument("--docs-dir")
|
|
1389
|
+
p = sub.add_parser("import")
|
|
1390
|
+
p.add_argument("bundle")
|
|
1391
|
+
p.add_argument("--dry-run", action="store_true")
|
|
1392
|
+
p.add_argument("--root")
|
|
1393
|
+
p.add_argument("--docs-dir")
|
|
965
1394
|
args = ap.parse_args(argv)
|
|
966
1395
|
if args.cmd == "claim-id":
|
|
967
1396
|
return kb_cmd_claim_id(args)
|
|
968
1397
|
if args.cmd == "anchor":
|
|
969
1398
|
return kb_cmd_anchor(args)
|
|
1399
|
+
if args.cmd == "export":
|
|
1400
|
+
return kb_cmd_export(args)
|
|
1401
|
+
if args.cmd == "import":
|
|
1402
|
+
return kb_cmd_import(args)
|
|
970
1403
|
try:
|
|
971
1404
|
root, docs = _kb_root(args)
|
|
972
1405
|
except sdlc_core.AmbiguousDocsRoot as e:
|
|
@@ -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.
|