@antoneeo/kb-agentic-skill 1.1.0 → 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 +2 -1
- package/gemini-extension.json +1 -1
- package/package.json +2 -1
- package/skills/kb-agentic-skill/SKILL.md +4 -2
- package/skills/kb-agentic-skill/distillation.md +1 -1
- 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 +290 -6
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
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kb-agentic-skill",
|
|
3
|
-
"version": "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.
|
|
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`). 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,7 +84,8 @@ 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 |
|
|
86
|
-
|
|
|
87
|
+
| a KB bundle (`export`) | Knowledge must leave this project. Export is L1 — it writes nothing into the corpus. **Importing one is L3**: a source enters the corpus and nodes are created, and a bundle is external input, so never L1 whatever its size (`portability.md`). | — |
|
|
88
|
+
| `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 |
|
|
89
91
|
|
|
@@ -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", "
|
|
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):
|
|
@@ -859,10 +1052,27 @@ def kb_cmd_claim_id(args):
|
|
|
859
1052
|
|
|
860
1053
|
|
|
861
1054
|
def kb_cmd_anchor(args):
|
|
862
|
-
"""Prose citation -> a verified span. The half `claim-id` never had.
|
|
1055
|
+
"""Prose citation -> a verified span. The half `claim-id` never had.
|
|
1056
|
+
|
|
1057
|
+
The path may be given as it appears in a claim's `source` cell
|
|
1058
|
+
(`corpus/given/x-ab12cd34.txt`): when it does not resolve from the current
|
|
1059
|
+
directory it is retried under the docs root, so the command works from
|
|
1060
|
+
anywhere in the project instead of only from inside `ai_docs/`. Reported
|
|
1061
|
+
from the field as an asymmetry with `graph`/`corpus`/`check`, which take
|
|
1062
|
+
`--root`; those scan a tree, this one takes a path, and the fix is to make
|
|
1063
|
+
the path resolve rather than to document where to stand."""
|
|
863
1064
|
p = Path(args.path)
|
|
864
1065
|
if not p.is_file():
|
|
865
|
-
|
|
1066
|
+
try:
|
|
1067
|
+
_, docs = _kb_root(args)
|
|
1068
|
+
if (docs / args.path).is_file():
|
|
1069
|
+
p = docs / args.path
|
|
1070
|
+
except sdlc_core.AmbiguousDocsRoot:
|
|
1071
|
+
pass
|
|
1072
|
+
if not p.is_file():
|
|
1073
|
+
print("[ERROR] no such file: %s" % args.path)
|
|
1074
|
+
print(" looked from the current directory and under the docs "
|
|
1075
|
+
"root; give the path as the claim's `source` cell carries it.")
|
|
866
1076
|
return 2
|
|
867
1077
|
hits = kb_resolve_anchor(p, args.phrase, ignore_case=args.ignore_case,
|
|
868
1078
|
page=args.page)
|
|
@@ -883,6 +1093,62 @@ def kb_cmd_anchor(args):
|
|
|
883
1093
|
return 0
|
|
884
1094
|
|
|
885
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
|
+
|
|
886
1152
|
def kb_cmd_help():
|
|
887
1153
|
"""The spine's usage, then the overlay's own commands.
|
|
888
1154
|
|
|
@@ -902,6 +1168,8 @@ knowledge overlay (kb-agentic) -- also available:
|
|
|
902
1168
|
corpus corpus integrity: digests, supersession, notes
|
|
903
1169
|
claim-id <path> <locator> compute a claim id (--fill to fill a whole table)
|
|
904
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)
|
|
905
1173
|
|
|
906
1174
|
index / validate / check the spine's behaviour PLUS the claim ledger and
|
|
907
1175
|
the topic graph""")
|
|
@@ -940,14 +1208,30 @@ def main(argv=None):
|
|
|
940
1208
|
p = sub.add_parser("anchor")
|
|
941
1209
|
p.add_argument("path")
|
|
942
1210
|
p.add_argument("phrase")
|
|
1211
|
+
p.add_argument("--root")
|
|
1212
|
+
p.add_argument("--docs-dir")
|
|
943
1213
|
p.add_argument("--page", type=int)
|
|
944
1214
|
p.add_argument("--ignore-case", action="store_true")
|
|
945
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")
|
|
946
1226
|
args = ap.parse_args(argv)
|
|
947
1227
|
if args.cmd == "claim-id":
|
|
948
1228
|
return kb_cmd_claim_id(args)
|
|
949
1229
|
if args.cmd == "anchor":
|
|
950
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)
|
|
951
1235
|
try:
|
|
952
1236
|
root, docs = _kb_root(args)
|
|
953
1237
|
except sdlc_core.AmbiguousDocsRoot as e:
|