@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.
@@ -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; a dangling `supersedes:`.
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 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.
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"). **A question
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
- - Its note must carry `imported_from:`. Unnamed, the class says nothing and the row is a
124
- `RULING` with the label filed off; the validator refuses it.
125
- - **It cannot settle anything here.** `SUPERSEDED <id>` pointing at an `IMPORTED` row is
126
- an error: nobody in this project granted that decision its authority.
127
-
128
- Re-ratification is one act, and it is you deciding: read it, write your own note with
129
- your own `basis:`, set `prov` to `RULING`.
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. **`model` records the capability
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"}.get(kind, "ok")}
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
- add(row["date"], "review", "%s — %s: %s" % (
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
- date = knowledge.frontmatter_date(
1255
- sdlc_core.load_frontmatter(text.splitlines()))
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
- add(date, kind, name, rel, source=name)
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
- if git_ignored(project, out) is False:
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())