@antoneeo/kb-agentic-skill 1.1.1 → 1.2.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/README.md CHANGED
@@ -23,11 +23,12 @@ Deliberately absent: any per-node coverage or completion state. `gaps:` says wha
23
23
  ## Key features
24
24
 
25
25
  - **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.
26
+ - **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
27
  - **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
28
  - **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
29
  - **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
30
  - **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.
31
+ - **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
32
  - **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
33
 
33
34
  ## Installation
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kb-agentic-skill",
3
- "version": "1.1.1",
3
+ "version": "1.2.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.1.1",
3
+ "version": "1.2.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,6 +84,7 @@ 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 |
88
90
  | `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 |
@@ -69,7 +69,7 @@ Claims live in the owning topic's `## Claims` table (`templates.md` has the temp
69
69
  `p=<n>@<start>-<end>` (character offsets into page n of the stored extraction),
70
70
  `L<a>-<b>` (line files), `Sheet<s>!<cell>`. The span must exist — the validator opens
71
71
  the file and checks.
72
- - **prov** — `GIVEN | ELICITED | DERIVED | RULING`. Information for whoever resolves a
72
+ - **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
73
  conflict; never a rank.
74
74
  - **state** — `OK`, `CONTESTED <ids>`, `SUPERSEDED <id>` (`reconciliation.md` owns the
75
75
  transitions).
@@ -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", "ENFORCEMENT.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,7 +85,7 @@ 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}$")
@@ -315,7 +320,7 @@ def kb_check_claims(root):
315
320
  prov = row["prov"]
316
321
  if prov not in PROVENANCES:
317
322
  errors.append("%s: prov %r not in %s" % (where, prov, "/".join(PROVENANCES)))
318
- elif prov in ("DERIVED", "RULING", "ELICITED"):
323
+ elif prov in ("DERIVED", "RULING", "ELICITED", "IMPORTED"):
319
324
  meta = _note_frontmatter(root, first.rsplit("#", 1)[0])
320
325
  if meta is None:
321
326
  pass # unresolvable source already reported
@@ -325,6 +330,13 @@ def kb_check_claims(root):
325
330
  elif prov == "RULING" and not meta.get("basis"):
326
331
  errors.append("%s: RULING note carries no 'basis:' — a preference "
327
332
  "is not a fact; no basis, no ruling" % where)
333
+ elif prov == "IMPORTED" and not meta.get("imported_from"):
334
+ # F-030: IMPORTED exists so a foreign decision cannot pass for
335
+ # a local one. Without the origin the class says nothing and
336
+ # the row is a RULING with the label filed off.
337
+ errors.append("%s: IMPORTED note carries no 'imported_from:' — "
338
+ "the class exists to name whose decision this "
339
+ "was; unnamed, it is a RULING in disguise" % where)
328
340
  # --- grammar cells ---
329
341
  try:
330
342
  kb_parse_scope(row["valid"])
@@ -384,6 +396,18 @@ def kb_check_claims(root):
384
396
  "the check, it does not clean up" % (where, kind, t))
385
397
  continue
386
398
  orow, _ = other
399
+ if kind == "SUPERSEDED" and orow["prov"] == "IMPORTED":
400
+ # F-030, owner ruling 2026-08-03: knowledge crosses a project
401
+ # boundary, authority does not. An IMPORTED row carries another
402
+ # owner's decision; letting it supersede a local row would make
403
+ # that decision binding here without anyone here granting it.
404
+ # Re-ratify first: write your own ruling note with your own
405
+ # basis and flip the row to RULING.
406
+ errors.append("%s: SUPERSEDED by %s, which is IMPORTED — a "
407
+ "foreign decision cannot settle a local row. "
408
+ "Re-ratify it (own note, own 'basis:', prov "
409
+ "RULING) or resolve this some other way"
410
+ % (where, t))
387
411
  if kind == "CONTESTED":
388
412
  if orow["state"].startswith("SUPERSEDED"):
389
413
  errors.append("%s: CONTESTED points at SUPERSEDED row %s — "
@@ -734,10 +758,179 @@ def kb_sha256_bytes(path):
734
758
  return h.hexdigest()
735
759
 
736
760
 
761
+ # ------------------------------------------------------- portability (F-030)
762
+ # Export a subgraph WITH the bytes its claims cite; import it additively.
763
+ #
764
+ # The bundle mirrors the docs-root layout on purpose: claim `source` cells are
765
+ # docs-root-relative, so nothing is rewritten on import and `kb_claim_id` --
766
+ # sha256(path#locator#qty), text excluded -- mints the SAME id in both projects.
767
+ # That is what makes de-duplication mechanical instead of a judgement call, and
768
+ # it is why this feature is small.
769
+
770
+ BUNDLE_MANIFEST = "MANIFEST.md"
771
+
772
+
773
+ def kb_claim_sources(row):
774
+ """The docs-root-relative artifact paths a claim row cites (no locators)."""
775
+ out = []
776
+ for src in row["source"].split(";"):
777
+ src = src.strip()
778
+ if src and "#" in src:
779
+ out.append(src.rsplit("#", 1)[0])
780
+ elif src:
781
+ out.append(src)
782
+ return out
783
+
784
+
785
+ def kb_collect_topics(docs):
786
+ """{slug: (path, text, rows)} for every topic node that parses."""
787
+ out = {}
788
+ tdir = docs / "topics"
789
+ if not tdir.is_dir():
790
+ return out
791
+ for p in sorted(tdir.glob("*.md")):
792
+ if p.name == "INDEX.md":
793
+ continue
794
+ text = sdlc_core.read_text(p)
795
+ meta = sdlc_core.load_frontmatter(text.splitlines()) or {}
796
+ rows, _ = kb_parse_claims(text)
797
+ out[(meta.get("topic") or p.stem).strip()] = (p, text, rows)
798
+ return out
799
+
800
+
801
+ def kb_export_closure(docs, slugs):
802
+ """(topics, artifacts, added_for_conflicts, errors).
803
+
804
+ Closure in two directions, because a partial export produces a target whose
805
+ own checks fail:
806
+ * every artifact a selected claim cites travels with it -- a claim whose
807
+ source cannot be reopened is model knowledge arriving by another route;
808
+ * every row a CONTESTED row points at travels too, since the symmetry
809
+ check refuses a set that lost half its members. When such a row lives in
810
+ an unselected topic, that topic is ADDED and reported, never dropped.
811
+ """
812
+ all_topics = kb_collect_topics(docs)
813
+ errors = []
814
+ for s in slugs:
815
+ if s not in all_topics:
816
+ errors.append("no such topic: %s" % s)
817
+ if errors:
818
+ return {}, [], [], errors
819
+ selected = dict((s, all_topics[s]) for s in slugs)
820
+ # id -> slug, over the WHOLE graph, so a conflict partner is findable
821
+ owner_of = {}
822
+ for slug, (_p, _t, rows) in all_topics.items():
823
+ for r in rows:
824
+ if r["id"]:
825
+ owner_of[r["id"]] = slug
826
+ added = []
827
+ pending = list(selected)
828
+ while pending:
829
+ slug = pending.pop()
830
+ for r in selected[slug][2]:
831
+ m = re.match(r"^(CONTESTED|SUPERSEDED) ([0-9a-f, ]+)$", r["state"].strip())
832
+ if not m:
833
+ continue
834
+ for ref in [x.strip() for x in m.group(2).split(",") if x.strip()]:
835
+ other = owner_of.get(ref)
836
+ if other is None:
837
+ errors.append("claim %s in topic '%s' points at id %s, which "
838
+ "no topic owns: export would carry a broken set"
839
+ % (r["id"] or "(no id)", slug, ref))
840
+ elif other not in selected:
841
+ selected[other] = all_topics[other]
842
+ added.append(other)
843
+ pending.append(other)
844
+ artifacts = []
845
+ seen = set()
846
+ for slug, (_p, _t, rows) in sorted(selected.items()):
847
+ for r in rows:
848
+ for rel in kb_claim_sources(r):
849
+ for cand in (rel, rel + ".meta.md",
850
+ rel[:-len(Path(rel).suffix)] + ".txt" if Path(rel).suffix else rel):
851
+ if cand in seen:
852
+ continue
853
+ if (docs / cand).is_file():
854
+ seen.add(cand)
855
+ artifacts.append(cand)
856
+ return selected, artifacts, added, errors
857
+
858
+
859
+ def kb_bundle_write(docs, out, selected, artifacts, project):
860
+ """Write the bundle. Mirrors the docs-root layout; no path is rewritten."""
861
+ out.mkdir(parents=True, exist_ok=True)
862
+ (out / "topics").mkdir(exist_ok=True)
863
+ for slug, (p, text, _rows) in sorted(selected.items()):
864
+ (out / "topics" / p.name).write_text(text, encoding="utf-8")
865
+ for rel in artifacts:
866
+ dst = out / rel
867
+ dst.parent.mkdir(parents=True, exist_ok=True)
868
+ dst.write_bytes((docs / rel).read_bytes())
869
+ lines = ["---", "kb_bundle: 1", "source_project: %s" % project,
870
+ "topics: [%s]" % ", ".join(sorted(selected)),
871
+ "artifacts: %d" % len(artifacts), "---",
872
+ "# KB bundle", "",
873
+ "Import with `sdlc_check.py import <this directory>`. Additive: it "
874
+ "never overwrites a node and never deletes anything.", ""]
875
+ for rel in artifacts:
876
+ lines.append("- `%s` sha256:%s" % (rel, kb_sha256_bytes(docs / rel)))
877
+ (out / BUNDLE_MANIFEST).write_text("\n".join(lines) + "\n", encoding="utf-8")
878
+
879
+
880
+ def kb_import_plan(bundle, docs):
881
+ """(writes, skipped_topics, dedup, errors) -- computed BEFORE anything is
882
+ written, because an import that half-applies leaves a tree whose checks fail
883
+ and whose owner cannot tell what landed."""
884
+ errors, writes, skipped, dedup = [], [], [], []
885
+ man = bundle / BUNDLE_MANIFEST
886
+ if not man.is_file():
887
+ return [], [], [], ["not a kb bundle: no %s in %s" % (BUNDLE_MANIFEST, bundle)]
888
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(man).splitlines()) or {}
889
+ if str(meta.get("kb_bundle", "")).strip() != "1":
890
+ return [], [], [], ["%s carries no 'kb_bundle: 1'" % BUNDLE_MANIFEST]
891
+
892
+ known_ids = set()
893
+ target = kb_collect_topics(docs)
894
+ for _slug, (_p, _t, rows) in target.items():
895
+ for r in rows:
896
+ if r["id"]:
897
+ known_ids.add(r["id"])
898
+
899
+ for p in sorted(bundle.rglob("*")):
900
+ if not p.is_file() or p.name == BUNDLE_MANIFEST:
901
+ continue
902
+ rel = p.relative_to(bundle).as_posix()
903
+ dst = sdlc_core.confine_under(docs, rel)
904
+ if dst is None:
905
+ errors.append("bundle entry %r escapes the docs root — refusing the "
906
+ "whole import, not just this file" % rel)
907
+ continue
908
+ if dst.is_file() and not rel.startswith("topics/"):
909
+ if kb_sha256_bytes(dst) != kb_sha256_bytes(p):
910
+ errors.append("%s exists with different bytes (target %s… vs "
911
+ "bundle %s…): content-addressed names must mean "
912
+ "equal content" % (rel, kb_sha256_bytes(dst)[:8],
913
+ kb_sha256_bytes(p)[:8]))
914
+ continue
915
+ if rel.startswith("topics/") and dst.is_file():
916
+ skipped.append(rel)
917
+ continue
918
+ writes.append((rel, p, dst))
919
+
920
+ for rel, p, _dst in writes:
921
+ if not rel.startswith("topics/"):
922
+ continue
923
+ rows, _ = kb_parse_claims(sdlc_core.read_text(p))
924
+ for r in rows:
925
+ if r["id"] and r["id"] in known_ids:
926
+ dedup.append(r["id"])
927
+ return writes, skipped, dedup, errors
928
+
929
+
737
930
  # ------------------------------------------------------------------ commands
738
931
 
739
932
  INTERCEPTED = {"index", "validate", "check", "graph", "corpus", "claim-id",
740
- "anchor"}
933
+ "anchor", "export", "import"}
741
934
 
742
935
 
743
936
  def _kb_root(args):
@@ -900,6 +1093,62 @@ def kb_cmd_anchor(args):
900
1093
  return 0
901
1094
 
902
1095
 
1096
+ def kb_cmd_export(args):
1097
+ root, docs = _kb_root(args)
1098
+ slugs = ([s.strip() for s in args.topics.split(",") if s.strip()]
1099
+ if args.topics else sorted(kb_collect_topics(docs)))
1100
+ if not slugs:
1101
+ print("[ERROR] no topics to export")
1102
+ return 2
1103
+ selected, artifacts, added, errors = kb_export_closure(docs, slugs)
1104
+ for e in errors:
1105
+ print("[ERROR] %s" % e)
1106
+ if errors:
1107
+ return 1
1108
+ out = Path(args.out)
1109
+ kb_bundle_write(docs, out, selected, artifacts, root.name)
1110
+ print("[ok] bundle written: %s" % out)
1111
+ print(" topics: %d, artifacts: %d" % (len(selected), len(artifacts)))
1112
+ if added:
1113
+ # never silent: a set that grew is a fact about the export, and the
1114
+ # alternative -- dropping the partner rows -- ships a broken tree.
1115
+ print(" +%d topic(s) added to keep conflict sets whole: %s"
1116
+ % (len(added), ", ".join(sorted(added))))
1117
+ return 0
1118
+
1119
+
1120
+ def kb_cmd_import(args):
1121
+ _root, docs = _kb_root(args)
1122
+ bundle = Path(args.bundle)
1123
+ if not bundle.is_dir():
1124
+ print("[ERROR] no such bundle directory: %s" % bundle)
1125
+ return 2
1126
+ writes, skipped, dedup, errors = kb_import_plan(bundle, docs)
1127
+ for e in errors:
1128
+ print("[ERROR] %s" % e)
1129
+ if errors:
1130
+ print("[ERROR] nothing was written: an import that half-applies leaves a "
1131
+ "tree whose checks fail and whose owner cannot tell what landed.")
1132
+ return 1
1133
+ if args.dry_run:
1134
+ print("[ok] dry run: %d file(s) would be written" % len(writes))
1135
+ else:
1136
+ for _rel, src, dst in writes:
1137
+ dst.parent.mkdir(parents=True, exist_ok=True)
1138
+ dst.write_bytes(src.read_bytes())
1139
+ print("[ok] imported %d file(s) into %s" % (len(writes), docs))
1140
+ if dedup:
1141
+ print(" %d claim(s) already present, by id — the same artifact cited "
1142
+ "at the same span mints the same id in any project" % len(dedup))
1143
+ for rel in skipped:
1144
+ print("[note] %s already exists: NOT overwritten. Run the placement pass "
1145
+ "(taxonomy.md) and merge by hand — an import never decides that."
1146
+ % rel)
1147
+ print("[note] re-run 'sdlc_check.py check' now: the import is additive, and "
1148
+ "an imported RULING stays IMPORTED until you re-ratify it.")
1149
+ return 0
1150
+
1151
+
903
1152
  def kb_cmd_help():
904
1153
  """The spine's usage, then the overlay's own commands.
905
1154
 
@@ -919,6 +1168,8 @@ knowledge overlay (kb-agentic) -- also available:
919
1168
  corpus corpus integrity: digests, supersession, notes
920
1169
  claim-id <path> <locator> compute a claim id (--fill to fill a whole table)
921
1170
  anchor <path> <phrase> resolve a phrase to a verified locator span
1171
+ export --out <dir> bundle a subgraph WITH the bytes its claims cite
1172
+ import <dir> merge a bundle in additively (never overwrites)
922
1173
 
923
1174
  index / validate / check the spine's behaviour PLUS the claim ledger and
924
1175
  the topic graph""")
@@ -962,11 +1213,25 @@ def main(argv=None):
962
1213
  p.add_argument("--page", type=int)
963
1214
  p.add_argument("--ignore-case", action="store_true")
964
1215
  p.add_argument("--all", action="store_true")
1216
+ p = sub.add_parser("export")
1217
+ p.add_argument("--out", required=True)
1218
+ p.add_argument("--topics")
1219
+ p.add_argument("--root")
1220
+ p.add_argument("--docs-dir")
1221
+ p = sub.add_parser("import")
1222
+ p.add_argument("bundle")
1223
+ p.add_argument("--dry-run", action="store_true")
1224
+ p.add_argument("--root")
1225
+ p.add_argument("--docs-dir")
965
1226
  args = ap.parse_args(argv)
966
1227
  if args.cmd == "claim-id":
967
1228
  return kb_cmd_claim_id(args)
968
1229
  if args.cmd == "anchor":
969
1230
  return kb_cmd_anchor(args)
1231
+ if args.cmd == "export":
1232
+ return kb_cmd_export(args)
1233
+ if args.cmd == "import":
1234
+ return kb_cmd_import(args)
970
1235
  try:
971
1236
  root, docs = _kb_root(args)
972
1237
  except sdlc_core.AmbiguousDocsRoot as e: