@antoneeo/kb-agentic-skill 1.28.0 → 1.32.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 +60 -0
- package/README.md +7 -7
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/skills/kb-agentic-skill/ENFORCEMENT.md +17 -5
- package/skills/kb-agentic-skill/SKILL.md +23 -8
- package/skills/kb-agentic-skill/distillation.md +66 -6
- package/skills/kb-agentic-skill/docs_site/app.js +7 -1
- package/skills/kb-agentic-skill/docs_site/strings.js +9 -0
- package/skills/kb-agentic-skill/docs_site/style.css +2 -0
- package/skills/kb-agentic-skill/guides.md +15 -1
- package/skills/kb-agentic-skill/memory.md +1 -1
- package/skills/kb-agentic-skill/portability.md +86 -16
- package/skills/kb-agentic-skill/reconciliation.md +31 -8
- package/skills/kb-agentic-skill/review.md +9 -1
- package/skills/kb-agentic-skill/routing.md +4 -0
- package/skills/kb-agentic-skill/scripts/docs_site.py +89 -6
- package/skills/kb-agentic-skill/scripts/knowledge.py +1444 -50
- package/skills/kb-agentic-skill/scripts/sdlc_core.py +1115 -75
- package/skills/kb-agentic-skill/templates.md +70 -6
|
@@ -39,7 +39,8 @@ sdlc_check.py check # always, right after
|
|
|
39
39
|
and, if a topic was skipped, *"a topic with the same name already exists; I have not
|
|
40
40
|
merged them, because they may not be the same subject. Do you want me to look?"*
|
|
41
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.
|
|
42
|
+
it, marked as theirs, and it cannot settle anything here until you confirm it. The
|
|
43
|
+
question it had closed is open again here, with their decision beside the sources."*
|
|
43
44
|
|
|
44
45
|
Do not explain the closure, the id hashing or the provenance model unless asked. The
|
|
45
46
|
user asked to move their knowledge, not to learn how it is stored.
|
|
@@ -73,13 +74,23 @@ You choose topics; the export decides what must travel with them.
|
|
|
73
74
|
half its members, so a partial export ships a tree that cannot pass its own checks.
|
|
74
75
|
When a partner row lives in an unselected topic, that topic is **added and reported** —
|
|
75
76
|
never dropped, never silently.
|
|
77
|
+
- **Not the `not-yet-honored-by` markers.** They describe this project's code, so the export drops them and says so, without a number.
|
|
78
|
+
- **Not the declarations.** A `DECLARED` row is this project's commitment to its own
|
|
79
|
+
recipient; carried elsewhere it would read as the importer's. The export leaves the
|
|
80
|
+
rows and the artifacts only they cite behind and says so in one line, without a
|
|
81
|
+
number. Nothing else is lost: no other row may point at a declaration, and the export
|
|
82
|
+
refuses one that does (run `check`).
|
|
76
83
|
|
|
77
84
|
If a conflict partner resolves to no row at all, the export **refuses**: exporting a
|
|
78
85
|
broken set is worse than exporting nothing.
|
|
79
86
|
|
|
87
|
+
A withdrawn artifact (`distillation.md` §1) never travels: it has no bytes, and its
|
|
88
|
+
tombstone sidecar stays home.
|
|
89
|
+
|
|
80
90
|
## 3. Import is additive, and decides nothing
|
|
81
91
|
|
|
82
|
-
`import` writes files. It does not place concepts, merge bodies, or settle disagreements
|
|
92
|
+
`import` writes files. It does not place concepts, merge bodies, or settle disagreements —
|
|
93
|
+
and it refuses to let the bundle settle them either: a ruling arrives foreign (§4).
|
|
83
94
|
|
|
84
95
|
- **Never overwrites a topic.** A slug that already exists is reported and skipped: two
|
|
85
96
|
projects using the word `pricing` may mean two different things, and that judgement is
|
|
@@ -92,7 +103,12 @@ broken set is worse than exporting nothing.
|
|
|
92
103
|
It refuses on: a missing or unmarked `MANIFEST.md`; a path that escapes the docs root; an
|
|
93
104
|
artifact whose name matches an existing one **with different bytes** (content-addressed
|
|
94
105
|
names must mean equal content — a mismatch means one of the two is lying about its
|
|
95
|
-
origin); an incomplete conflict set
|
|
106
|
+
origin); an incomplete conflict set — a `CONTESTED` or `SUPERSEDED` pointer that would
|
|
107
|
+
resolve to no row, or to a row of this project that does not name it back (an import
|
|
108
|
+
never edits an existing topic); a row settled by a ruling the import does not write, or
|
|
109
|
+
by a row this project holds as `IMPORTED`; a ruling it cannot carry foreign (§4); a
|
|
110
|
+
`DECLARED` row (an export never ships one, so the bundle was edited by hand). Each
|
|
111
|
+
refusal names the row and writes nothing.
|
|
96
112
|
|
|
97
113
|
After importing, run `check`. The import is deliberately not a validator.
|
|
98
114
|
|
|
@@ -104,22 +120,76 @@ another owner's decision, and importing it unchanged would make that decision bi
|
|
|
104
120
|
here without anyone here granting it — the machine deciding, which reconciliation
|
|
105
121
|
refuses everywhere else.
|
|
106
122
|
|
|
107
|
-
So
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
So `import` makes every `RULING` row of a topic it writes arrive as **`prov: IMPORTED`**
|
|
124
|
+
(owner rulings 2026-08-03 and 2026-10-23):
|
|
125
|
+
|
|
126
|
+
- **its note arrives as a renamed copy**, `corpus/notes/imported_<source project>_<name>`,
|
|
127
|
+
whose frontmatter adds `imported_from: <source project> — <original path>` and reads
|
|
128
|
+
`origin: imported`. Its text and original `basis:` travel **verbatim** — the knowledge
|
|
129
|
+
is not lost, and pretending it is a `DERIVED` synthesis would make the row lie about
|
|
130
|
+
where it came from. The note is never edited under its own name: a line inserted into
|
|
131
|
+
it would move the line every locator cites. The row cites the copy at the same text
|
|
132
|
+
(its locator and id move with it), and a bundle note `derived_from:` the ruling names
|
|
133
|
+
the copy. The original name is never written, so a row copied by hand out of a skipped
|
|
134
|
+
topic cannot cite a foreign note as local;
|
|
135
|
+
- **the question it had closed reopens.** The rows it superseded and the ruling itself
|
|
136
|
+
arrive as one `CONTESTED` set; a ruling already contested with a newer source keeps
|
|
137
|
+
that partner, one that superseded nothing arrives `OK`;
|
|
138
|
+
- the validator refuses an `IMPORTED` row whose note lacks `imported_from:` (unnamed,
|
|
139
|
+
the class says nothing), a `SUPERSEDED <id>` pointing at an `IMPORTED` row, and a
|
|
140
|
+
`RULING` row on a note that names a foreign origin.
|
|
141
|
+
|
|
142
|
+
**Ratification** is the ordinary ruling of `reconciliation.md`, and it is you deciding,
|
|
143
|
+
which is the only thing that ever settles anything here: write your own note with your
|
|
144
|
+
own `basis:`, add a `RULING` row on it, and mark every row `CONTESTED` with the imported
|
|
145
|
+
one — following the sets transitively — `SUPERSEDED <your id>`, the `IMPORTED` row
|
|
146
|
+
included. Relabelling the `IMPORTED` row `RULING` is refused: another project's note
|
|
147
|
+
cannot carry your decision.
|
|
148
|
+
|
|
149
|
+
Any bundle note that declares a `basis:` lands this way, renamed, even when its ruling row
|
|
150
|
+
does not travel with it — otherwise it would land first under its own name and a later
|
|
151
|
+
import of its ruling would find it there. A ruling `import` cannot carry this way is
|
|
152
|
+
refused, never copied: one not resting on a note cited by line, on a note with no
|
|
153
|
+
frontmatter, or on a note that already names a foreign origin (the source relabelled an
|
|
154
|
+
imported row); a row superseded both by a ruling and by a row that is not one; a ruling
|
|
155
|
+
another row superseded while rows still depend on it. A tree imported before this
|
|
156
|
+
conversion existed (≤ kb 1.31.0) holds the foreign ruling as `RULING` on its original
|
|
157
|
+
note: delete the imported topic and its files and import again, or convert it by hand as
|
|
158
|
+
above. The origin is named after the exporting folder (`source_project`), so two clones
|
|
159
|
+
of one project in differently named folders name it differently.
|
|
120
160
|
|
|
121
161
|
## 5. Triage
|
|
122
162
|
|
|
123
163
|
Exporting is L1 — it writes nothing into the corpus and changes no knowledge. Importing
|
|
124
164
|
is **L3**: a source enters the corpus and nodes are created, which is the L3 definition
|
|
125
165
|
in Rule Zero, and the bundle is external input, which is never L1 whatever its size.
|
|
166
|
+
Adoption (§6) is **L3** for the same reason.
|
|
167
|
+
|
|
168
|
+
## 6. Adopting kb-agentic on an existing `ai_docs/`
|
|
169
|
+
|
|
170
|
+
A project that used agentic-sdlc first has distilled its documents into
|
|
171
|
+
`source_kind: document` guides whose sources are snapshots in `reference/.sources/`.
|
|
172
|
+
With a corpus, a document's text has one home, `corpus/given/` (`distillation.md` §1).
|
|
173
|
+
Moving it is one L3 unit:
|
|
174
|
+
|
|
175
|
+
1. **Inventory.** `sdlc_check.py adopt` lists each document guide, where its source is
|
|
176
|
+
(`.sources/` snapshot, already in `given/`, elsewhere, missing), a `given/` artifact
|
|
177
|
+
holding the same text, and credential-shaped lines. It writes nothing; there is no
|
|
178
|
+
`--apply`, because steps 2 and 3 are judgements.
|
|
179
|
+
2. **Choose the source, per guide.** The original, when it is reachable and is the
|
|
180
|
+
version the guide was distilled from — it is complete. Otherwise the snapshot, which
|
|
181
|
+
is a verbatim excerpt: its sidecar says so in its prose line ("verbatim excerpt of
|
|
182
|
+
<original>, selected for <guide> on <date>"), with `original_ref:` or
|
|
183
|
+
`original_path:` when known, and what the excerpt left out is a `gaps:` entry on the
|
|
184
|
+
topic, never assumed read.
|
|
185
|
+
3. **Redact** (`distillation.md` §1), then store the artifact under its content-addressed
|
|
186
|
+
name, with its sidecar.
|
|
187
|
+
4. **Re-point the guide**: `distilled_from:`, `source_hash` (the line-ending-normalized
|
|
188
|
+
digest), and its markers now name the artifact. The guide keeps its name.
|
|
189
|
+
5. **Delete the snapshot**, then run `check` and `stale`.
|
|
190
|
+
|
|
191
|
+
Past decisions are not rewritten: analyses, ADRs and diaries that cite the guide keep
|
|
192
|
+
citing it, and a citation of the old snapshot path resolves in git history. A secret
|
|
193
|
+
already committed in a snapshot stays in that history — say so to the user; purging it
|
|
194
|
+
is theirs. Extracting claims from the adopted artifacts is an ordinary ingestion,
|
|
195
|
+
afterwards.
|
|
@@ -24,6 +24,15 @@ Subject-sameness is semantic judgement; no query performs it. Classify:
|
|
|
24
24
|
| **coexistence** | contradictory only if scopes overlapped — and by the half-open rule they do not | both rows stay `OK`. "12k until March" and "15k from March" are two truths, not a conflict |
|
|
25
25
|
| **conflict** | contradictory, scopes overlap | **every** row in the set becomes `CONTESTED` listing all counterparts. Nothing is picked |
|
|
26
26
|
|
|
27
|
+
**A declaration is not a source.** A `DECLARED` row (`distillation.md` §1: what this
|
|
28
|
+
project sent) never joins the five outcomes with a fact: it never corroborates a row,
|
|
29
|
+
never refines or supersedes one, never contests one. Against the ledger it is either
|
|
30
|
+
consistent (`OK`) or it **diverges**: its assertion contradicts a non-superseded,
|
|
31
|
+
non-DECLARED row, scopes overlapping → the DECLARED row alone becomes
|
|
32
|
+
`DIVERGES <id>[, ..]`; the ledger row is not touched, because our own wrong statement
|
|
33
|
+
puts nothing about the product in doubt. Two declarations that contradict each other
|
|
34
|
+
are `CONTESTED` — both are still held by their recipients.
|
|
35
|
+
|
|
27
36
|
The machine then verifies (the `graph` check): ids recompute, sources resolve and their
|
|
28
37
|
spans exist, `CONTESTED` is **symmetric** (editing one cell to `OK` fails the check —
|
|
29
38
|
the cheapest laundering), a `CONTESTED` pointer at a `SUPERSEDED` row is an error,
|
|
@@ -73,8 +82,13 @@ premise: signed amendments arrive later.
|
|
|
73
82
|
| newer source vs a ruling | new set {new row, ruling row}; escalation shows the `basis:` |
|
|
74
83
|
| member refined | the refining row inherits the member's contested relations (re-judged); counterparts rewritten to the new id |
|
|
75
84
|
| corroboration of a contested member | source appended; the set untouched — more evidence is not new information, and the escalation form shows source counts |
|
|
85
|
+
| a declaration contradicts the ledger | the DECLARED row → `DIVERGES <ledger ids>`; the ledger rows stay as they are; escalation names it |
|
|
86
|
+
| a new send corrects it | the new DECLARED row; the divergent one → `SUPERSEDED <new-id>` (a declaration is superseded only by a later declaration) |
|
|
87
|
+
| a ruling changes the decision | the ledger row → `SUPERSEDED <ruling-id>`; `check` then refuses the stale `DIVERGES` until the declaration is re-judged against the ruling: `OK` if it now agrees, `DIVERGES <ruling-id>` if not |
|
|
76
88
|
| bundled row split (`distillation.md` §3) | N atomic rows `OK`, each on its narrowed span; the bundle → `SUPERSEDED <id1>[, <id2> ..]`, text intact; contested relations re-judged per successor |
|
|
77
89
|
|
|
90
|
+
A superseded row keeps its `not-yet-honored-by` marker with its text. Copy the marker to each successor that still states behavior the code does not have; the analysis or shadow citing the predecessor's id satisfies the successor.
|
|
91
|
+
|
|
78
92
|
## 3-bis. Splitting a bundled row
|
|
79
93
|
|
|
80
94
|
A row carrying more than one assertion (`distillation.md` §3 — atomic as provenance,
|
|
@@ -101,7 +115,11 @@ record like any claim-table edit.
|
|
|
101
115
|
Escalations are presented **once, at the end of a run** — the pipeline never stops
|
|
102
116
|
mid-ingest to ask. Each escalation names: the claims in the set, each one's source
|
|
103
117
|
(reopenable), date and provenance, and one line on why the machine cannot decide
|
|
104
|
-
("same subject, overlapping validity, GIVEN vs GIVEN, no newer source").
|
|
118
|
+
("same subject, overlapping validity, GIVEN vs GIVEN, no newer source"). A divergence
|
|
119
|
+
is escalated the same way, naming the recipient and the day it was sent: the
|
|
120
|
+
practitioner corrects it with a new send or changes the decision. One limit stated
|
|
121
|
+
out loud: `DIVERGES` is one-way, so unlike `CONTESTED` a hand edit back to `OK` has
|
|
122
|
+
no counterpart to fail on — the diff review is what catches it. **A question
|
|
105
123
|
that cannot fill this form is not askable** — it is the symptom that the answer is in
|
|
106
124
|
the corpus and was not searched. Generic confirmations are not questions.
|
|
107
125
|
|
|
@@ -120,10 +138,15 @@ An `IMPORTED` row is a ruling made by the owner of a different project
|
|
|
120
138
|
|
|
121
139
|
- It keeps its text, its span and its original `basis:` verbatim — the knowledge is not
|
|
122
140
|
lost, and relabelling it `DERIVED` would make the row lie about its origin.
|
|
123
|
-
-
|
|
124
|
-
`
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
141
|
+
- It rests on a renamed copy of its note (`imported_<project>_<name>`) that carries
|
|
142
|
+
`imported_from:`. Unnamed, the class says nothing and the row is a `RULING` with the
|
|
143
|
+
label filed off; the validator refuses it.
|
|
144
|
+
- **It cannot settle anything here.** `import` reopens the set it had settled: its former
|
|
145
|
+
members and the ruling arrive `CONTESTED` with each other. `SUPERSEDED <id>` pointing at
|
|
146
|
+
an `IMPORTED` row is an error: nobody in this project granted that decision its
|
|
147
|
+
authority.
|
|
148
|
+
|
|
149
|
+
Ratification is the ruling procedure above, and it is you deciding: your own note with
|
|
150
|
+
your own `basis:`, a `RULING` row on it, and every row `CONTESTED` with the imported one —
|
|
151
|
+
transitively, when it sits in more than one set — `SUPERSEDED <your id>`, the imported row
|
|
152
|
+
included. A `RULING` on a note that names a foreign origin is refused.
|
|
@@ -187,7 +187,10 @@ can block forever gets removed. **Log one row per completed review, PASS or FAIL
|
|
|
187
187
|
logging only passes would erase exactly that evidence. The row goes in
|
|
188
188
|
`ai_docs/audit/reviews/REVIEW_LOG.md` (create it if absent — `templates.md`):
|
|
189
189
|
`| date | doc_key | tier | model | reviewer | findings_raised | findings_real | verdict | revise_rounds |`,
|
|
190
|
-
with `tier` = `design` or `closure` in Standalone.
|
|
190
|
+
with `tier` = `design` or `closure` in Standalone. In the code lens, a wrong behaviour
|
|
191
|
+
the owner reports in a merged L3 change is one row too, tier `post-merge`, `model`
|
|
192
|
+
`none (owner report)`, reviewer `owner`, the owner quoted in the notes: a defect
|
|
193
|
+
record, not a review, so `benefit` keeps it out of every review figure. **`model` records the capability
|
|
191
194
|
tier that actually ran** — `deep`, `light`, `economy`, `single (client exposes no
|
|
192
195
|
choice)`, `below floor: <reason>`, or `none (not due)` on a NOT DUE row — never a provider name in doctrine (a project
|
|
193
196
|
may of course write its own model names in its own log). Without it the floor above
|
|
@@ -285,6 +288,11 @@ The binding inputs, by review type:
|
|
|
285
288
|
clause reads both), and the **probe harness** (`ai_docs/solutions/harness_[feature]/`).
|
|
286
289
|
Hand these *in addition to* the design artifact — the reviewer checks the artifact
|
|
287
290
|
**against** them, not only for internal consistency.
|
|
291
|
+
- **The constraint view comes last.** Where the ANALYSIS carries ids, the reviewer
|
|
292
|
+
derives the callers and behaviours first, then compares with
|
|
293
|
+
`sdlc_check.py constraints --unit <id>`: a constraint the design states and the
|
|
294
|
+
diff breaks is a finding. The view never replaces the ANALYSIS or the diff in the
|
|
295
|
+
request.
|
|
288
296
|
- **For a closure review, the diff and the test evidence**: the run output that
|
|
289
297
|
backs every completion claim, dated after the final relevant change.
|
|
290
298
|
- **For a design or closure review of an L3/E3 unit, the request record**
|
|
@@ -92,6 +92,10 @@ Run the steps in order. Stop at the first one that decides.
|
|
|
92
92
|
| "research sources and teach a newcomer a topic" | purpose: teaching → course | never reached | never reached | **course** |
|
|
93
93
|
| "ingest these manuals, then create a course" | split: corpus = supplied docs; training = teaching | never reached | never reached | **knowledge** for ingestion; **course** for training |
|
|
94
94
|
| "write sales copy and an onboarding course" | split: persuasion and teaching | never reached | never reached | **marketing** for copy; **course** for training |
|
|
95
|
+
| "write the interface spec of our service for another team" (kept and versioned with the repo) | code | repo's own doc set → code | not build-consumed → no flip | **code** |
|
|
96
|
+
| "write the requirements another team must build, from their documents and our notes" | supplied docs → knowledge | never reached | never reached | **knowledge** |
|
|
97
|
+
| "the vendor spec changed: update the guide distilled from it" | split (step 4): guide = supplied docs; any code change the new spec requires = this repo | never reached | never reached | **knowledge** for re-distilling the guide; **code** for the code change, citing the guide |
|
|
98
|
+
| "the code and the supplied spec diverge" | split (step 4): changing the code = this repo; correcting the distillation = supplied docs | never reached | never reached | **code** if the code is wrong; **knowledge** if the distillation is — which one is wrong is the owner's call, not the router's |
|
|
95
99
|
|
|
96
100
|
## 3. Acting on the verdict
|
|
97
101
|
|
|
@@ -33,6 +33,7 @@ import re
|
|
|
33
33
|
import shutil
|
|
34
34
|
import subprocess
|
|
35
35
|
import sys
|
|
36
|
+
import zipfile
|
|
36
37
|
import unicodedata
|
|
37
38
|
import urllib.parse
|
|
38
39
|
from datetime import datetime
|
|
@@ -614,6 +615,11 @@ class Grounds:
|
|
|
614
615
|
return [{"kind": "untraceable", "path": rel}]
|
|
615
616
|
seen = seen | {rel}
|
|
616
617
|
if rel.startswith("corpus/given/"):
|
|
618
|
+
# F-084: a document this project SENT is our own declaration, not
|
|
619
|
+
# ground for a fact about the product.
|
|
620
|
+
side = self._meta(rel + ".meta.md")
|
|
621
|
+
if _v(side, "provenance").upper() == "DECLARED":
|
|
622
|
+
return [{"kind": "declared", "path": rel}]
|
|
617
623
|
return [{"kind": "document", "path": rel}]
|
|
618
624
|
if not _inside(self.docs, rel) or not (self.docs / rel).is_file():
|
|
619
625
|
return [{"kind": "untraceable", "path": rel}]
|
|
@@ -642,6 +648,8 @@ def ground_class(leaves):
|
|
|
642
648
|
return "statement"
|
|
643
649
|
if "untraceable" in kinds:
|
|
644
650
|
return "untraceable"
|
|
651
|
+
if "declared" in kinds:
|
|
652
|
+
return "declared"
|
|
645
653
|
if "kb" in kinds:
|
|
646
654
|
return "imported"
|
|
647
655
|
return "documents"
|
|
@@ -706,12 +714,15 @@ def build_kb(docs):
|
|
|
706
714
|
kind, rest = _kind(r)
|
|
707
715
|
f = {"text": r["claim"], "valid": r["valid"], "qty": r["qty"], "about": r["about"],
|
|
708
716
|
"origin": (r["prov"] or "").strip().lower(), "topic": slug,
|
|
709
|
-
"state": {"SUPERSEDED": "superseded", "CONTESTED": "contested"
|
|
717
|
+
"state": {"SUPERSEDED": "superseded", "CONTESTED": "contested",
|
|
718
|
+
"DIVERGES": "diverges"}.get(kind, "ok")}
|
|
710
719
|
if kind == "SUPERSEDED":
|
|
711
720
|
f["successors"] = _ids(rest)
|
|
712
721
|
f["chain_ends"] = chain_ends(r["id"], by_id)
|
|
713
722
|
if kind == "CONTESTED":
|
|
714
723
|
f["with"] = _ids(rest)
|
|
724
|
+
if kind == "DIVERGES":
|
|
725
|
+
f["diverges_from"] = _ids(rest)
|
|
715
726
|
srcs = []
|
|
716
727
|
for src in r["source"].split(";"):
|
|
717
728
|
src = src.strip()
|
|
@@ -767,6 +778,8 @@ def build_sources(docs, rows_of):
|
|
|
767
778
|
name = mp.name[:-len(".meta.md")]
|
|
768
779
|
t = _text(mp) or ""
|
|
769
780
|
meta = sdlc_core.load_frontmatter(t.splitlines()) or {}
|
|
781
|
+
if knowledge.kb_is_tombstone(meta):
|
|
782
|
+
continue # withdrawn: no bytes to show; corpus/INDEX.md keeps the row
|
|
770
783
|
through = _v(meta, "extracted_through")
|
|
771
784
|
parsed = knowledge.kb_parse_extracted_through(through) if through else None
|
|
772
785
|
if not through:
|
|
@@ -1221,7 +1234,14 @@ def chronology(docs, pages):
|
|
|
1221
1234
|
text = _text(p)
|
|
1222
1235
|
# a shadow mirror is not an analysis to `validate` (list_analyses): not here either
|
|
1223
1236
|
if text is not None and not sdlc_core.is_shadow(p, (text.splitlines() or [""])[0]):
|
|
1237
|
+
# a Diary label ("closed", "Approved (R5)") names no unit: prefix the id, so
|
|
1238
|
+
# the event reads on its own in the chronology (field case 2026-10-19)
|
|
1239
|
+
uid = dict((k.lower(), v) for k, v in split_frontmatter(text)[0]).get("id", "").strip()
|
|
1240
|
+
start = len(events)
|
|
1224
1241
|
openers("solutions/" + p.name, "diary", sdlc_core.DIARY_SECTION)
|
|
1242
|
+
if uid:
|
|
1243
|
+
for e in events[start:]:
|
|
1244
|
+
e["label"] = "%s — %s" % (uid, e["label"])
|
|
1225
1245
|
# spike outcomes: SPIKE_* in the code/kb/course lenses, RESEARCH_* in marketing
|
|
1226
1246
|
for rel in sorted(["solutions/" + p.name for p in docs.glob("solutions/SPIKE_*.md")]
|
|
1227
1247
|
+ ["spikes/" + p.name for p in docs.glob("spikes/RESEARCH_*.md")]):
|
|
@@ -1236,7 +1256,9 @@ def chronology(docs, pages):
|
|
|
1236
1256
|
text = _text(docs / rel) if (docs / rel).is_file() else None
|
|
1237
1257
|
if text is not None:
|
|
1238
1258
|
for row in sdlc_core.parse_review_log(text)[0]:
|
|
1239
|
-
|
|
1259
|
+
# F-085: a `post-merge` row is the owner's defect report, not a review
|
|
1260
|
+
kind = "defect" if row["tier"].lower().startswith("post-merge") else "review"
|
|
1261
|
+
add(row["date"], kind, "%s — %s: %s" % (
|
|
1240
1262
|
row["doc_key"], row["tier"], row["verdict"]), rel)
|
|
1241
1263
|
for p in sorted(docs.glob("architecture/ADR_*.md")):
|
|
1242
1264
|
rel = "architecture/" + p.name
|
|
@@ -1251,13 +1273,17 @@ def chronology(docs, pages):
|
|
|
1251
1273
|
text = _text(p)
|
|
1252
1274
|
if text is None:
|
|
1253
1275
|
continue
|
|
1254
|
-
|
|
1255
|
-
|
|
1276
|
+
fm = sdlc_core.load_frontmatter(text.splitlines())
|
|
1277
|
+
date = knowledge.frontmatter_date(fm)
|
|
1256
1278
|
if not date:
|
|
1257
1279
|
continue
|
|
1280
|
+
if kind == "source" and knowledge.kb_is_tombstone(fm):
|
|
1281
|
+
continue
|
|
1258
1282
|
if kind == "source":
|
|
1259
1283
|
name = p.name[:-len(suffix)]
|
|
1260
|
-
|
|
1284
|
+
# F-084: a DECLARED sidecar's date is the day it was SENT
|
|
1285
|
+
sent = _v(fm, "provenance").upper() == "DECLARED"
|
|
1286
|
+
add(date, "sent" if sent else kind, name, rel, source=name)
|
|
1261
1287
|
else:
|
|
1262
1288
|
add(date, kind, title_of(rel, text), rel)
|
|
1263
1289
|
# Most recent first. Within one day a document's later entry is the more recent
|
|
@@ -1289,6 +1315,29 @@ def check_out(out, docs):
|
|
|
1289
1315
|
return out
|
|
1290
1316
|
|
|
1291
1317
|
|
|
1318
|
+
def check_zip(target, docs, out):
|
|
1319
|
+
"""Where `--zip` may write: a .zip file outside the docs root and outside the site."""
|
|
1320
|
+
target = target.resolve()
|
|
1321
|
+
if target.suffix.lower() != ".zip":
|
|
1322
|
+
raise SiteError("--zip %s must name a .zip file." % target)
|
|
1323
|
+
for folder, what in ((docs.resolve(), "the docs root"), (out.resolve(), "the site folder")):
|
|
1324
|
+
if target == folder or folder in target.parents:
|
|
1325
|
+
raise SiteError("--zip %s is inside %s %s." % (target, what, folder))
|
|
1326
|
+
if target.exists() and not target.is_file():
|
|
1327
|
+
raise SiteError("--zip %s exists and is not a file." % target)
|
|
1328
|
+
return target
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def write_zip(out, target):
|
|
1332
|
+
"""The site as one file to share. Entry names always use '/': an archive whose names
|
|
1333
|
+
carry '\\' (Windows PowerShell 5.1 Compress-Archive) unpacks on Linux and macOS as
|
|
1334
|
+
files named `data\\core.js`, and the page then finds no data (field case 2026-10-19)."""
|
|
1335
|
+
with zipfile.ZipFile(str(target), "w", zipfile.ZIP_DEFLATED) as z:
|
|
1336
|
+
for p in sorted(out.rglob("*")):
|
|
1337
|
+
if p.is_file() and p.name != MARKER:
|
|
1338
|
+
z.write(str(p), p.relative_to(out).as_posix())
|
|
1339
|
+
|
|
1340
|
+
|
|
1292
1341
|
def write_site(out, core, pages, kb):
|
|
1293
1342
|
out.mkdir(parents=True, exist_ok=True)
|
|
1294
1343
|
for rel in PAGE_FILES + ("data",):
|
|
@@ -1324,6 +1373,30 @@ def git_ignored(project, out):
|
|
|
1324
1373
|
return None
|
|
1325
1374
|
|
|
1326
1375
|
|
|
1376
|
+
def ensure_ignored(project, out):
|
|
1377
|
+
"""Add the site folder to the project's .gitignore when git does not ignore it, so the
|
|
1378
|
+
site's copy of the documents is not committed. An entry already in the file is never
|
|
1379
|
+
rewritten (git may ignore it elsewhere, or the user negated it on purpose). Returns the
|
|
1380
|
+
line added, or None; False when the folder stays not ignored."""
|
|
1381
|
+
if git_ignored(project, out) is not False:
|
|
1382
|
+
return None
|
|
1383
|
+
rel = out.resolve().relative_to(project.resolve()).as_posix()
|
|
1384
|
+
entry = rel + "/"
|
|
1385
|
+
gi = project / ".gitignore"
|
|
1386
|
+
try:
|
|
1387
|
+
raw = gi.read_bytes() if gi.is_file() else b""
|
|
1388
|
+
text = raw.decode("utf-8")
|
|
1389
|
+
if {rel, entry, "/" + rel, "/" + entry} & {l.strip() for l in text.splitlines()}:
|
|
1390
|
+
return False
|
|
1391
|
+
nl = "\r\n" if "\r\n" in text else "\n"
|
|
1392
|
+
sep = nl if text and not text.endswith(("\n", "\r")) else ""
|
|
1393
|
+
with open(str(gi), "ab") as f:
|
|
1394
|
+
f.write((sep + entry + nl).encode("utf-8"))
|
|
1395
|
+
except (OSError, UnicodeDecodeError):
|
|
1396
|
+
return False
|
|
1397
|
+
return entry
|
|
1398
|
+
|
|
1399
|
+
|
|
1327
1400
|
def _shown(path, docs):
|
|
1328
1401
|
try:
|
|
1329
1402
|
return Path(path).relative_to(docs).as_posix()
|
|
@@ -1339,11 +1412,14 @@ def main(argv=None):
|
|
|
1339
1412
|
ap.add_argument("--docs-dir", dest="docs_dir", help="docs root name (ai_docs or mkt_docs)")
|
|
1340
1413
|
ap.add_argument("--out", help="output folder (default: <project>/%s)" % DEFAULT_OUT)
|
|
1341
1414
|
ap.add_argument("--open", action="store_true", help="open the site in the browser")
|
|
1415
|
+
ap.add_argument("--zip", metavar="FILE",
|
|
1416
|
+
help="also write the site as one .zip to share; it opens on any system")
|
|
1342
1417
|
args = ap.parse_args(argv)
|
|
1343
1418
|
del _UNREADABLE[:]
|
|
1344
1419
|
try:
|
|
1345
1420
|
project, docs, name = resolve_root(args)
|
|
1346
1421
|
out = check_out(Path(args.out) if args.out else project / DEFAULT_OUT, docs)
|
|
1422
|
+
target = check_zip(Path(args.zip), docs, out) if args.zip else None
|
|
1347
1423
|
except SiteError as exc:
|
|
1348
1424
|
print("[ERROR] %s" % exc)
|
|
1349
1425
|
return 2
|
|
@@ -1366,9 +1442,16 @@ def main(argv=None):
|
|
|
1366
1442
|
", %d topics, %d facts" % (len(kb["order"]), len(kb["facts"])) if kb else "", docs))
|
|
1367
1443
|
for path, exc in _UNREADABLE:
|
|
1368
1444
|
print("[ERROR] unreadable: %s (%s)" % (_shown(path, docs), exc))
|
|
1369
|
-
|
|
1445
|
+
added = ensure_ignored(project, out)
|
|
1446
|
+
if added:
|
|
1447
|
+
print("[ok] added %s to %s so the site's copy of your documents is not committed."
|
|
1448
|
+
% (added, project / ".gitignore"))
|
|
1449
|
+
elif added is False:
|
|
1370
1450
|
print("[note] %s is not git-ignored: add it to .gitignore so the site's copy of "
|
|
1371
1451
|
"your documents is not committed." % out)
|
|
1452
|
+
if target:
|
|
1453
|
+
write_zip(out, target)
|
|
1454
|
+
print("[ok] docs site zipped: %s (unzip it and open index.html)" % target)
|
|
1372
1455
|
if args.open:
|
|
1373
1456
|
import webbrowser
|
|
1374
1457
|
webbrowser.open((out / "index.html").as_uri())
|