fusion-cli 1.1.0__py3-none-any.whl

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.
Files changed (46) hide show
  1. fusion/__init__.py +3 -0
  2. fusion/_skills/fusion-analyst/SKILL.md +50 -0
  3. fusion/_skills/fusion-analyst/references/assess.md +12 -0
  4. fusion/_skills/fusion-analyst/references/compare.md +12 -0
  5. fusion/_skills/fusion-analyst/references/export.md +18 -0
  6. fusion/_skills/fusion-analyst/references/fusion-conventions.md +149 -0
  7. fusion/_skills/fusion-analyst/references/report.md +13 -0
  8. fusion/_skills/fusion-analyst/scripts/export.py +64 -0
  9. fusion/_skills/fusion-intake/SKILL.md +128 -0
  10. fusion/_skills/fusion-intake/references/convert.md +104 -0
  11. fusion/_skills/fusion-intake/references/delivery.md +107 -0
  12. fusion/_skills/fusion-intake/references/fusion-conventions.md +149 -0
  13. fusion/_skills/fusion-intake/references/gate.md +107 -0
  14. fusion/_skills/fusion-intake/scripts/convert.py +836 -0
  15. fusion/_skills/fusion-intake/scripts/gate.py +267 -0
  16. fusion/_skills/fusion-librarian/SKILL.md +64 -0
  17. fusion/_skills/fusion-librarian/references/archive.md +21 -0
  18. fusion/_skills/fusion-librarian/references/create.md +14 -0
  19. fusion/_skills/fusion-librarian/references/cross-reference.md +45 -0
  20. fusion/_skills/fusion-librarian/references/fusion-conventions.md +149 -0
  21. fusion/_skills/fusion-librarian/references/promote.md +24 -0
  22. fusion/_skills/fusion-librarian/references/query.md +17 -0
  23. fusion/_skills/fusion-librarian/references/reflect.md +47 -0
  24. fusion/_skills/fusion-librarian/references/restructure.md +20 -0
  25. fusion/_skills/fusion-librarian/references/tag.md +13 -0
  26. fusion/_skills/fusion-librarian/scripts/link-repair.py +371 -0
  27. fusion/_skills/fusion-planner/SKILL.md +62 -0
  28. fusion/_skills/fusion-planner/references/close.md +12 -0
  29. fusion/_skills/fusion-planner/references/create-activity.md +38 -0
  30. fusion/_skills/fusion-planner/references/fusion-conventions.md +149 -0
  31. fusion/_skills/fusion-planner/references/horizon.md +20 -0
  32. fusion/bucket.py +77 -0
  33. fusion/checker.py +248 -0
  34. fusion/cli.py +406 -0
  35. fusion/document.py +155 -0
  36. fusion/hub.py +78 -0
  37. fusion/indexer.py +75 -0
  38. fusion/ledger.py +106 -0
  39. fusion/manifest.py +33 -0
  40. fusion/scaffold.py +120 -0
  41. fusion/setup.py +300 -0
  42. fusion/views.py +111 -0
  43. fusion_cli-1.1.0.dist-info/METADATA +67 -0
  44. fusion_cli-1.1.0.dist-info/RECORD +46 -0
  45. fusion_cli-1.1.0.dist-info/WHEEL +4 -0
  46. fusion_cli-1.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,836 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = [
5
+ # "openpyxl>=3.1.0",
6
+ # "PyYAML>=6.0",
7
+ # "pymupdf>=1.24.0",
8
+ # ]
9
+ # ///
10
+ """fusion-intake Stage-1 engine: admit / prepare / link / batch / cleanup.
11
+
12
+ Deterministic, no LLM. `admit` is the ONLY writer of sources/MANIFEST.md in
13
+ the whole Fusion reference implementation. `prepare` routes by format:
14
+ extractive files (xlsx/csv) become conformant documents directly; everything
15
+ else gets a work dir under workbench/.intake/ with page text + images +
16
+ manifest.json for the Stage-2 agent (references/convert.md). The page-
17
+ coverage invariant — every page recorded, low-text pages flagged
18
+ needs_vision — makes silent-empty output structurally impossible. `batch`
19
+ runs a validated admit+link op-list in one process for delivery-scale
20
+ intake (references/delivery.md) — it reuses `admit`/`link`, never a second
21
+ writer.
22
+ """
23
+ import argparse
24
+ import csv as csv_mod
25
+ import hashlib
26
+ import json
27
+ import posixpath
28
+ import re
29
+ import shutil
30
+ import subprocess
31
+ import sys
32
+ import uuid
33
+ import zipfile
34
+ from datetime import datetime
35
+ from email import policy
36
+ from email.parser import BytesParser
37
+ from html.parser import HTMLParser
38
+ from pathlib import Path
39
+
40
+ import yaml
41
+
42
+ AURORAS = ("commitments", "focus", "ops", "collab", "life", "explore",
43
+ "archive", "library")
44
+
45
+ EXTRACTIVE_EXTS = {".xlsx", ".csv"}
46
+ LIBREOFFICE_EXTS = {".docx", ".pptx", ".doc", ".odt", ".rtf", ".key",
47
+ ".pages", ".ppt", ".xls", ".html", ".htm"}
48
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
49
+ MAIL_EXTS = {".eml"}
50
+ TEXT_EXTS = {".md", ".txt", ".json", ".yaml", ".yml"}
51
+ SUPPORTED_EXTS = (EXTRACTIVE_EXTS | LIBREOFFICE_EXTS | IMAGE_EXTS
52
+ | MAIL_EXTS | TEXT_EXTS | {".pdf"})
53
+
54
+ # Containers are DELIVERY VEHICLES, not originals: `unpack` extracts them
55
+ # into inbox/ and discards the zip — the members become the originals.
56
+ # They must never join SUPPORTED_EXTS; admit keeps refusing them.
57
+ CONTAINER_EXTS = {".zip", ".athena"}
58
+
59
+ # Zones a `link` doc path may point into — inbox/sources/workbench never
60
+ # hold finished documents, only originals and ephemeral staging.
61
+ DOC_ZONES = {"library", "activities", "output"}
62
+
63
+ TEXT_COVERAGE_MIN_CHARS = 100 # below this a page is scanned/figure
64
+ RENDER_DPI = 150
65
+ EXCEL_ERRORS = {"#REF!", "#N/A", "#VALUE!", "#DIV/0!", "#NAME?", "#NULL!",
66
+ "#NUM!"}
67
+
68
+ TODAY = datetime.now().strftime("%Y-%m-%d")
69
+
70
+
71
+ class IntakeError(Exception):
72
+ """Strict-writer refusal — named loudly, never silently skipped."""
73
+
74
+
75
+ # ── shared helpers ───────────────────────────────────────────────────────
76
+
77
+ def sha256_of(path: Path) -> str:
78
+ h = hashlib.sha256()
79
+ with open(path, "rb") as fh:
80
+ for chunk in iter(lambda: fh.read(65536), b""):
81
+ h.update(chunk)
82
+ return h.hexdigest()
83
+
84
+
85
+ def slugify(name: str) -> str:
86
+ """SPEC §4 filename: lowercase, hyphen-separated, stem <=60 chars."""
87
+ stem = re.sub(r"\.(xlsx|docx|pptx|csv|pdf|eml|md|txt|png|jpe?g|webp|gif|html?|json|ya?ml)$",
88
+ "", name, flags=re.IGNORECASE)
89
+ stem = stem.lower()
90
+ stem = re.sub(r"[^a-z0-9]+", "-", stem).strip("-")
91
+ stem = re.sub(r"-+", "-", stem)
92
+ return stem[:60].rstrip("-") or "document"
93
+
94
+
95
+ def cell_to_string(cell) -> str:
96
+ if isinstance(cell, str) and cell.strip() in EXCEL_ERRORS:
97
+ return ""
98
+ if cell is None:
99
+ return ""
100
+ if isinstance(cell, bool):
101
+ return str(cell)
102
+ if isinstance(cell, float):
103
+ if cell.is_integer():
104
+ return str(int(cell))
105
+ return repr(cell) # shortest round-trippable form — verbatim, never rounded
106
+ if isinstance(cell, int):
107
+ return str(cell)
108
+ if isinstance(cell, datetime):
109
+ return cell.strftime("%Y-%m-%d")
110
+ text = str(cell).replace("|", "\\|").replace("\n", "<br>")
111
+ return text.strip()
112
+
113
+
114
+ def _row_empty(row) -> bool:
115
+ return all(cell_to_string(c) == "" for c in row)
116
+
117
+
118
+ def prune_empty_columns(rows):
119
+ if not rows:
120
+ return rows
121
+ width = max(len(r) for r in rows)
122
+ padded = [list(r) + [None] * (width - len(r)) for r in rows]
123
+ keep = [i for i in range(width)
124
+ if any(cell_to_string(r[i]) != "" for r in padded)]
125
+ return [[r[i] for i in keep] for r in padded] if keep else []
126
+
127
+
128
+ def rows_to_table(rows) -> str:
129
+ """One markdown table. Every row, every column — no caps, no sampling."""
130
+ rows = [r for r in rows if not _row_empty(r)]
131
+ rows = prune_empty_columns(rows)
132
+ if not rows or not rows[0]:
133
+ return "*No data*"
134
+ string_rows = [[cell_to_string(c) for c in r] for r in rows]
135
+ lines = ["| " + " | ".join(string_rows[0]) + " |",
136
+ "| " + " | ".join(["---"] * len(rows[0])) + " |"]
137
+ lines += ["| " + " | ".join(r) + " |" for r in string_rows[1:]]
138
+ return "\n".join(lines)
139
+
140
+
141
+ def render_document(fm: dict, summary: str, body: str) -> str:
142
+ front = yaml.safe_dump(fm, sort_keys=False, allow_unicode=True,
143
+ width=2**31 - 1)
144
+ return f"---\n{front}---\n\n## Summary\n\n{summary}\n\n---\n\n{body}\n"
145
+
146
+
147
+ # ── MANIFEST (single writer lives here) ──────────────────────────────────
148
+
149
+ def _manifest_path(root: Path) -> Path:
150
+ return root / "sources" / "MANIFEST.md"
151
+
152
+
153
+ def manifest_append(root: Path, rel: str, actor: str, sha: str) -> None:
154
+ path = _manifest_path(root)
155
+ if not path.is_file():
156
+ path.write_text("# Manifest\n\n| file | added | by | sha256 | library |\n"
157
+ "|---|---|---|---|---|\n", encoding="utf-8", newline="\n")
158
+ text = path.read_text(encoding="utf-8")
159
+ if not text.endswith("\n"):
160
+ text += "\n"
161
+ text += f"| {rel} | {TODAY} | {actor} | {sha} | — |\n"
162
+ path.write_text(text, encoding="utf-8", newline="\n")
163
+
164
+
165
+ def manifest_link(root: Path, rel: str, doc: str) -> None:
166
+ path = _manifest_path(root)
167
+ if not path.is_file():
168
+ raise IntakeError(f"no manifest at {path}")
169
+ lines = path.read_text(encoding="utf-8").splitlines()
170
+ hit = False
171
+ for i, line in enumerate(lines):
172
+ if not line.strip().startswith("|"):
173
+ continue
174
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
175
+ if len(cells) == 5 and cells[0] == rel:
176
+ cells[4] = doc if cells[4] in ("—", "") else f"{cells[4]}, {doc}"
177
+ lines[i] = "| " + " | ".join(cells) + " |"
178
+ hit = True
179
+ break
180
+ if not hit:
181
+ raise IntakeError(f"source not in manifest: {rel}")
182
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
183
+
184
+
185
+ def _manifest_rels(root: Path) -> set:
186
+ """The `file` column of every real row — used by `batch` to validate
187
+ (category, basename) uniqueness against what's already registered."""
188
+ path = _manifest_path(root)
189
+ if not path.is_file():
190
+ return set()
191
+ rels = set()
192
+ for line in path.read_text(encoding="utf-8").splitlines():
193
+ if not line.strip().startswith("|"):
194
+ continue
195
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
196
+ if len(cells) == 5 and cells[0] not in ("file", "---"):
197
+ rels.add(cells[0])
198
+ return rels
199
+
200
+
201
+ # ── admit ────────────────────────────────────────────────────────────────
202
+
203
+ def admit(root: Path, inbox_rel: str, category: str, actor: str) -> dict:
204
+ root = Path(root)
205
+ # Resolve the inbox root once and require the target to stay under it —
206
+ # inbox_rel is untrusted (may carry ../ traversal) and a LEXICAL
207
+ # relative_to() check downstream would accept a resolved parent that has
208
+ # already escaped. Check this BEFORE any other validation, including
209
+ # is_file() (mirrors unpack()'s pattern, commit 8851fd3) — admit is the
210
+ # only writer of sources/MANIFEST.md and every caller (batch included)
211
+ # goes through this one function, so the guard lives here once.
212
+ inbox_root = (root / "inbox").resolve()
213
+ src = root / "inbox" / inbox_rel
214
+ src_resolved = src.resolve()
215
+ if not src_resolved.is_relative_to(inbox_root):
216
+ raise IntakeError(f"inbox path escapes inbox/: {inbox_rel!r}")
217
+ if not src.is_file():
218
+ raise IntakeError(f"not in inbox/: {inbox_rel}")
219
+ if src.suffix.lower() not in SUPPORTED_EXTS:
220
+ raise IntakeError(
221
+ f"unsupported format: {src.suffix.lower()} — the gate refuses "
222
+ "what it cannot preserve; the file stays in inbox/")
223
+ if not actor or any(c.isspace() for c in actor):
224
+ raise IntakeError(f"actor must be a single token: {actor!r}")
225
+ category = category.strip().strip("/")
226
+ if not category:
227
+ raise IntakeError("category required")
228
+ if any(c in src.name for c in "|\n\r") or any(c in category for c in "|\n\r"):
229
+ raise IntakeError(
230
+ f"'|' and newlines break the manifest grammar: {src.name!r} — "
231
+ "rename the incoming file before admitting it")
232
+ dest = root / "sources" / category / src.name
233
+ if dest.exists():
234
+ raise IntakeError(
235
+ f"sources/ is immutable and {category}/{src.name} already "
236
+ "exists — rename the incoming file before admitting it")
237
+ dest.parent.mkdir(parents=True, exist_ok=True)
238
+ shutil.move(str(src), str(dest))
239
+ sha = sha256_of(dest)
240
+ rel = f"{category}/{src.name}"
241
+ manifest_append(root, rel, actor, sha)
242
+ return {"source": rel, "sha256": sha, "manifest_row": True}
243
+
244
+
245
+ # ── unpack: containers are delivery vehicles, not originals ─────────────
246
+
247
+ def unpack(root: Path, inbox_rel: str) -> dict:
248
+ """Extract a container (.zip / .athena) sitting in inbox/ into a sibling
249
+ folder — the members become the originals, the container is discarded.
250
+ Does NOT write the ledger: the operator signs a `noted` entry via
251
+ `fusion log` (single-writer)."""
252
+ root = Path(root)
253
+ # Resolve the inbox root once and require every path we touch to stay
254
+ # under it — inbox_rel is untrusted (may carry ../ traversal) and a
255
+ # LEXICAL relative_to() check downstream would accept a resolved parent
256
+ # that has already escaped. Check this BEFORE any other validation.
257
+ inbox_root = (root / "inbox").resolve()
258
+ src = root / "inbox" / inbox_rel
259
+ src_resolved = src.resolve()
260
+ if not src_resolved.is_relative_to(inbox_root):
261
+ raise IntakeError(
262
+ f"container path escapes inbox/: {inbox_rel!r}")
263
+ if not src.is_file():
264
+ raise IntakeError(f"not in inbox/: {inbox_rel}")
265
+ if src.suffix.lower() not in CONTAINER_EXTS:
266
+ raise IntakeError(
267
+ f"not a container: {src.suffix.lower()} — unpack only handles "
268
+ f"{sorted(CONTAINER_EXTS)}")
269
+ if not zipfile.is_zipfile(src):
270
+ raise IntakeError(f"not a readable zip: {inbox_rel}")
271
+
272
+ stem = src_resolved.stem
273
+ dest = src_resolved.parent / stem # sibling of the container — nested
274
+ # vehicles keep their folder context, not
275
+ # inbox root — computed from the RESOLVED
276
+ # src so a traversal-crafted parent can't
277
+ # smuggle a dest outside inbox/ either.
278
+ if not dest.is_relative_to(inbox_root):
279
+ raise IntakeError(
280
+ f"container path escapes inbox/: {inbox_rel!r}")
281
+ dest_rel = str(dest.relative_to(inbox_root.parent))
282
+ if dest.exists():
283
+ raise IntakeError(
284
+ f"destination already exists: {dest_rel} — refusing to "
285
+ "merge a container into existing content")
286
+
287
+ with zipfile.ZipFile(src) as zf:
288
+ members = []
289
+ for info in zf.infolist():
290
+ if info.is_dir():
291
+ continue
292
+ name = info.filename
293
+ if name.startswith("__MACOSX/") or Path(name).name.startswith("._"):
294
+ continue
295
+ members.append(info)
296
+
297
+ # Validate every destination BEFORE writing anything — a hostile
298
+ # member fails the whole unpack, not just itself.
299
+ dest_resolved = dest.resolve()
300
+ targets = []
301
+ for info in members:
302
+ target = (dest / info.filename).resolve()
303
+ if not target.is_relative_to(dest_resolved):
304
+ raise IntakeError(
305
+ f"zip-slip attempt in {inbox_rel}: hostile member "
306
+ f"{info.filename!r} escapes {dest_rel}/ — unpack refused")
307
+ targets.append((info, target))
308
+
309
+ dest.mkdir(parents=True)
310
+ try:
311
+ for info, target in targets:
312
+ target.parent.mkdir(parents=True, exist_ok=True)
313
+ with zf.open(info) as fh, open(target, "wb") as out:
314
+ shutil.copyfileobj(fh, out)
315
+ except Exception:
316
+ shutil.rmtree(dest, ignore_errors=True)
317
+ raise
318
+
319
+ src.unlink()
320
+ return {"unpacked": len(targets), "dir": dest_rel}
321
+
322
+
323
+ # ── prepare: routing + engines ───────────────────────────────────────────
324
+
325
+ def probe_pdf_text_layer(pdf_path: Path):
326
+ import fitz
327
+ records = []
328
+ doc = fitz.open(str(pdf_path))
329
+ try:
330
+ for i, page in enumerate(doc, 1):
331
+ text = page.get_text().strip()
332
+ records.append({"page": i, "text": text, "text_chars": len(text),
333
+ "needs_vision": len(text) < TEXT_COVERAGE_MIN_CHARS})
334
+ finally:
335
+ doc.close()
336
+ return records
337
+
338
+
339
+ def render_pdf_pages(pdf_path: Path, out_dir: Path, pages=None):
340
+ import fitz
341
+ out_dir.mkdir(parents=True, exist_ok=True)
342
+ written = []
343
+ doc = fitz.open(str(pdf_path))
344
+ try:
345
+ targets = (list(range(1, doc.page_count + 1))
346
+ if pages is None else sorted(pages))
347
+ for n in targets:
348
+ if 1 <= n <= doc.page_count:
349
+ pix = doc.load_page(n - 1).get_pixmap(dpi=RENDER_DPI)
350
+ img = out_dir / f"page-{n:03d}.png"
351
+ pix.save(str(img))
352
+ written.append(img)
353
+ finally:
354
+ doc.close()
355
+ return written
356
+
357
+
358
+ def require_soffice() -> str:
359
+ exe = shutil.which("soffice") or shutil.which("libreoffice")
360
+ if not exe:
361
+ raise IntakeError(
362
+ "LibreOffice not found: 'soffice' must be on PATH for "
363
+ "docx/pptx/legacy office formats (declared in SKILL.md "
364
+ "compatibility). Install it or add it to PATH. No fallback.")
365
+ return exe
366
+
367
+
368
+ def soffice_to_pdf(src: Path, out_dir: Path) -> Path:
369
+ exe = require_soffice()
370
+ out_dir.mkdir(parents=True, exist_ok=True)
371
+ proc = subprocess.run(
372
+ [exe, "--headless", "--convert-to", "pdf", "--outdir",
373
+ str(out_dir), str(src)],
374
+ capture_output=True, text=True, timeout=300)
375
+ pdf = out_dir / (src.stem + ".pdf")
376
+ if proc.returncode != 0 or not pdf.exists():
377
+ raise IntakeError(
378
+ f"soffice failed on {src.name} (rc={proc.returncode}): "
379
+ f"{proc.stderr.strip()[:400]}")
380
+ return pdf
381
+
382
+
383
+ class _HTMLText(HTMLParser):
384
+ """Text of an HTML mail body: entities decoded (convert_charrefs),
385
+ script/style dropped whole, block tags become line breaks."""
386
+ _SKIP = {"script", "style"}
387
+ _BREAK = {"p", "br", "div", "tr", "li", "table",
388
+ "h1", "h2", "h3", "h4", "h5", "h6"}
389
+
390
+ def __init__(self):
391
+ super().__init__(convert_charrefs=True)
392
+ self._chunks: list = []
393
+ self._skip_depth = 0
394
+
395
+ def handle_starttag(self, tag, attrs):
396
+ if tag in self._SKIP:
397
+ self._skip_depth += 1
398
+ elif tag in self._BREAK:
399
+ self._chunks.append("\n")
400
+
401
+ def handle_endtag(self, tag):
402
+ if tag in self._SKIP and self._skip_depth:
403
+ self._skip_depth -= 1
404
+ elif tag in self._BREAK:
405
+ self._chunks.append("\n")
406
+
407
+ def handle_data(self, data):
408
+ if not self._skip_depth:
409
+ self._chunks.append(data)
410
+
411
+
412
+ def html_to_text(html: str) -> str:
413
+ parser = _HTMLText()
414
+ parser.feed(html)
415
+ parser.close()
416
+ lines = [re.sub(r"[ \t]+", " ", ln).strip()
417
+ for ln in "".join(parser._chunks).splitlines()]
418
+ return "\n".join(ln for ln in lines if ln)
419
+
420
+
421
+ def eml_to_text(path: Path, work_dir: Path):
422
+ msg = BytesParser(policy=policy.default).parse(open(path, "rb"))
423
+ lines = [f"{h}: {msg[h]}" for h in ("From", "To", "Date", "Subject")
424
+ if msg[h]]
425
+ body = msg.get_body(preferencelist=("plain", "html"))
426
+ content = body.get_content() if body else ""
427
+ if body and body.get_content_subtype() == "html":
428
+ content = html_to_text(content)
429
+ attachments = []
430
+ seen_lower = set()
431
+ for part in msg.iter_attachments():
432
+ name = Path(part.get_filename() or "attachment.bin").name
433
+ if name in {"", ".", ".."}:
434
+ name = "attachment.bin"
435
+ stem, dot, ext = name.partition(".")
436
+ n = 2
437
+ while name.lower() in seen_lower:
438
+ name = f"{stem}-{n}{dot}{ext}"
439
+ n += 1
440
+ (work_dir / name).write_bytes(part.get_payload(decode=True) or b"")
441
+ attachments.append(name)
442
+ seen_lower.add(name.lower())
443
+ return "\n".join(lines) + "\n\n" + content, attachments
444
+
445
+
446
+ def _route(ext: str) -> str:
447
+ ext = ext.lower()
448
+ if ext in EXTRACTIVE_EXTS:
449
+ return "extractive"
450
+ if ext in LIBREOFFICE_EXTS:
451
+ return "libreoffice"
452
+ if ext == ".pdf":
453
+ return "pdf"
454
+ if ext in IMAGE_EXTS:
455
+ return "image"
456
+ if ext in MAIL_EXTS:
457
+ return "mail"
458
+ if ext in TEXT_EXTS:
459
+ return "text"
460
+ raise IntakeError(f"unsupported format: {ext} — the gate refuses "
461
+ "what it cannot preserve")
462
+
463
+
464
+ def _sheet_matrix(ws):
465
+ """All cell values with merged ranges unfolded — every cell a merge
466
+ spans carries the anchor's value, so pruning never eats merged data."""
467
+ rows = [list(r) for r in ws.iter_rows(values_only=True)]
468
+ for rng in ws.merged_cells.ranges:
469
+ if rng.min_row - 1 >= len(rows):
470
+ continue
471
+ anchor_row = rows[rng.min_row - 1]
472
+ anchor = (anchor_row[rng.min_col - 1]
473
+ if rng.min_col - 1 < len(anchor_row) else None)
474
+ for r in range(rng.min_row, min(rng.max_row, len(rows)) + 1):
475
+ row = rows[r - 1]
476
+ for c in range(rng.min_col, min(rng.max_col, len(row)) + 1):
477
+ row[c - 1] = anchor
478
+ return rows
479
+
480
+
481
+ def _xlsx_body(path: Path):
482
+ import openpyxl
483
+ wb = openpyxl.load_workbook(path, data_only=True)
484
+ sections, sheets, rows_total = [], 0, 0
485
+ for name in wb.sheetnames:
486
+ rows = _sheet_matrix(wb[name])
487
+ live = [r for r in rows if not _row_empty(r)]
488
+ if live:
489
+ sheets += 1
490
+ rows_total += max(len(live) - 1, 0)
491
+ sections.append(f"## {name}\n\n{rows_to_table(rows)}")
492
+ return "\n\n".join(sections) or "*No data*", sheets, rows_total
493
+
494
+
495
+ def _csv_body(path: Path):
496
+ with open(path, "r", encoding="utf-8-sig", newline="") as fh:
497
+ rows = list(csv_mod.reader(fh))
498
+ return rows_to_table(rows), 1, max(len(rows) - 1, 0)
499
+
500
+
501
+ def _work_dir(root: Path) -> Path:
502
+ d = root / "workbench" / ".intake" / uuid.uuid4().hex[:12]
503
+ d.mkdir(parents=True, exist_ok=True)
504
+ return d
505
+
506
+
507
+ def _read_frontmatter(path: Path) -> dict:
508
+ """Tolerant frontmatter reader: {} if the leading --- block is absent
509
+ or unparsable — reconcile must never choke on a hand-edited document."""
510
+ text = path.read_text(encoding="utf-8")
511
+ if not text.startswith("---\n"):
512
+ return {}
513
+ parts = text.split("---\n", 2)
514
+ if len(parts) < 3:
515
+ return {}
516
+ try:
517
+ fm = yaml.safe_load(parts[1])
518
+ except yaml.YAMLError:
519
+ return {}
520
+ return fm if isinstance(fm, dict) else {}
521
+
522
+
523
+ def _merge_reconcile_fm(existing_fm: dict, seed: dict) -> dict:
524
+ """The librarian curated the existing document — its values win.
525
+ Existing keys come first (unknown keys preserved verbatim, including
526
+ the original `created:`); title/type/aurora are filled from the seed
527
+ only where the existing doc lacks them; `source:` always repoints to
528
+ the new original; `updated:` is always bumped to today."""
529
+ merged = dict(existing_fm)
530
+ for key in ("title", "type", "aurora"):
531
+ if not merged.get(key):
532
+ merged[key] = seed[key]
533
+ if "created" not in merged:
534
+ merged["created"] = seed["created"]
535
+ merged["source"] = seed["source"]
536
+ merged["updated"] = TODAY
537
+ return merged
538
+
539
+
540
+ def prepare(root: Path, source_rel: str, dest: str | None = None,
541
+ slug: str | None = None, doc_type: str | None = None,
542
+ aurora: str = "library", reconcile: bool = False) -> dict:
543
+ root = Path(root)
544
+ src = root / "sources" / source_rel
545
+ if not src.is_file():
546
+ raise IntakeError(f"not in sources/: {source_rel}")
547
+ if aurora not in AURORAS:
548
+ raise IntakeError(f"aurora must be one of the eight, got: {aurora!r}")
549
+ category = Path(source_rel).parts[0] if "/" in source_rel else "reference"
550
+ doc_type = doc_type or category.rstrip("s")
551
+ dest = (dest or f"library/{category}").strip("/")
552
+ slug = slug or slugify(src.name)
553
+ out_rel = f"{dest}/{slug}.md"
554
+ seed = {"title": src.stem, "type": doc_type, "aurora": aurora,
555
+ "source": f"sources/{source_rel}", "created": TODAY}
556
+ path = _route(src.suffix)
557
+
558
+ if path == "extractive":
559
+ if src.suffix.lower() == ".xlsx":
560
+ body, sheets, nrows = _xlsx_body(src)
561
+ else:
562
+ body, sheets, nrows = _csv_body(src)
563
+ summary = (f"Tabular data converted from {src.name}: "
564
+ f"{sheets} sheet(s), {nrows} data row(s).")
565
+ out = root / out_rel
566
+ if out.exists() and not reconcile:
567
+ raise IntakeError(
568
+ f"document exists: {out_rel} — pass --reconcile for a "
569
+ "confirmed update, or choose --slug")
570
+ if reconcile and out.exists():
571
+ seed = _merge_reconcile_fm(_read_frontmatter(out), seed)
572
+ out.parent.mkdir(parents=True, exist_ok=True)
573
+ out.write_text(render_document(seed, summary, body), encoding="utf-8",
574
+ newline="\n")
575
+ return {"path": "extractive", "done": True, "source": source_rel,
576
+ "output_file": out_rel, "front_matter_seed": seed,
577
+ "reconcile": reconcile}
578
+
579
+ # vision / structured paths get a work dir + manifest for Stage 2
580
+ out = root / out_rel
581
+ if out.exists() and not reconcile:
582
+ raise IntakeError(
583
+ f"document exists: {out_rel} — pass --reconcile for a "
584
+ "confirmed update, or choose --slug")
585
+ if reconcile and out.exists():
586
+ seed = _merge_reconcile_fm(_read_frontmatter(out), seed)
587
+
588
+ work = _work_dir(root)
589
+ record = {"path": path, "done": False, "source": source_rel,
590
+ "run_dir": str(work.relative_to(root)),
591
+ "output_file": out_rel, "front_matter_seed": seed,
592
+ "reconcile": reconcile,
593
+ "pages": [], "images": [], "attachments": [],
594
+ "intermediate_pdf": None}
595
+
596
+ if path == "libreoffice":
597
+ pdf = soffice_to_pdf(src, work)
598
+ record["intermediate_pdf"] = str(pdf.relative_to(root))
599
+ record["pages"] = probe_pdf_text_layer(pdf)
600
+ imgs = render_pdf_pages(pdf, work) # ALL pages
601
+ record["images"] = [str(p.relative_to(root)) for p in imgs]
602
+ elif path == "pdf":
603
+ probe = probe_pdf_text_layer(src)
604
+ record["pages"] = probe
605
+ if all(p["needs_vision"] for p in probe):
606
+ record["path"] = "pdf_scanned"
607
+ imgs = render_pdf_pages(src, work) # ALL pages
608
+ else:
609
+ record["path"] = "pdf_text"
610
+ imgs = render_pdf_pages(
611
+ src, work, pages=[p["page"] for p in probe if p["needs_vision"]])
612
+ record["images"] = [str(p.relative_to(root)) for p in imgs]
613
+ elif path == "image":
614
+ copy = work / src.name
615
+ shutil.copy2(src, copy)
616
+ record["pages"] = [{"page": 1, "text": "", "text_chars": 0,
617
+ "needs_vision": True}]
618
+ record["images"] = [str(copy.relative_to(root))]
619
+ elif path == "mail":
620
+ text, attachments = eml_to_text(src, work)
621
+ record["pages"] = [{"page": 1, "text": text,
622
+ "text_chars": len(text), "needs_vision": False}]
623
+ record["attachments"] = attachments
624
+ else: # text passthrough
625
+ text = src.read_text(encoding="utf-8", errors="replace")
626
+ record["pages"] = [{"page": 1, "text": text,
627
+ "text_chars": len(text), "needs_vision": False}]
628
+
629
+ record["page_count"] = len(record["pages"])
630
+ manifest = work / "manifest.json"
631
+ manifest.write_text(json.dumps(record, indent=2), encoding="utf-8",
632
+ newline="\n")
633
+ record["manifest"] = str(manifest.relative_to(root))
634
+ return record
635
+
636
+
637
+ def link(root: Path, source_rel: str, doc_rel: str) -> dict:
638
+ manifest_link(Path(root), source_rel, doc_rel)
639
+ return {"source": source_rel, "library": doc_rel}
640
+
641
+
642
+ # ── batch: one op-list, validated whole, then moved ──────────────────────
643
+ #
644
+ # Schema (references/delivery.md):
645
+ # {"admits": [{"file": "<inbox-rel>", "category": "<cat>"}],
646
+ # "links": [{"source": "<sources-rel>", "doc": "<zone-rel-doc>"}]}
647
+ #
648
+ # Semantics — two validation passes, never partial:
649
+ # 1. VALIDATE every admit and link syntactically/structurally BEFORE any
650
+ # filesystem or MANIFEST mutation (the gate's rule, batch-scale): a
651
+ # bad op anywhere in the list means nothing moves, nothing is
652
+ # appended — not even the good ops ahead of it.
653
+ # 2. EXECUTE all admits (reusing `admit()`, one MANIFEST row per file).
654
+ # 3. VALIDATE that every link's doc exists on disk NOW — admits already
655
+ # landed (the mechanical half doesn't roll back), but if any doc is
656
+ # missing NO link is written, not even the ones whose doc is fine.
657
+ # 4. EXECUTE all links (reusing `link()`).
658
+
659
+ def batch(root: Path, ops: dict, actor: str) -> dict:
660
+ root = Path(root)
661
+ if not actor or any(c.isspace() for c in actor):
662
+ raise IntakeError(f"actor must be a single token: {actor!r}")
663
+
664
+ admits = ops.get("admits", [])
665
+ links = ops.get("links", [])
666
+
667
+ # ── phase 1: validate every op, nothing touched yet ──────────────────
668
+ existing_rels = _manifest_rels(root)
669
+ batch_rels = set()
670
+ seen_files = set()
671
+ for i, op in enumerate(admits):
672
+ file = (op.get("file") or "").strip()
673
+ category = (op.get("category") or "").strip().strip("/")
674
+ if not file:
675
+ raise IntakeError(f"admits[{i}]: file required")
676
+ if not category:
677
+ raise IntakeError(f"admits[{i}]: category required")
678
+ if file in seen_files:
679
+ raise IntakeError(
680
+ f"admits[{i}]: {file!r} is admitted twice in this batch — "
681
+ "the first admit would move it before the second runs")
682
+ seen_files.add(file)
683
+ src = root / "inbox" / file
684
+ if not src.is_file():
685
+ raise IntakeError(f"admits[{i}]: not in inbox/: {file}")
686
+ if src.suffix.lower() not in SUPPORTED_EXTS:
687
+ raise IntakeError(
688
+ f"admits[{i}]: unsupported format: {src.suffix.lower()} — "
689
+ "the gate refuses what it cannot preserve; the file stays "
690
+ "in inbox/")
691
+ if any(c in src.name for c in "|\n\r") or any(c in category for c in "|\n\r"):
692
+ raise IntakeError(
693
+ f"admits[{i}]: '|' and newlines break the manifest "
694
+ f"grammar: {src.name!r}")
695
+ rel = f"{category}/{src.name}"
696
+ # A hand-placed file in sources/ with no MANIFEST row is a collision
697
+ # too — checking existing_rels alone lets a mixed batch admit past
698
+ # it, then fail mid-batch at admit()'s own dest.exists() with no
699
+ # rollback, breaking the all-or-nothing promise (references/delivery.md).
700
+ if rel in existing_rels or (root / "sources" / rel).exists():
701
+ raise IntakeError(
702
+ f"admits[{i}]: sources/ is immutable and {rel} already "
703
+ "exists — rename the incoming file before admitting it")
704
+ if rel in batch_rels:
705
+ raise IntakeError(
706
+ f"admits[{i}]: duplicate (category, basename) within this "
707
+ f"batch: {rel}")
708
+ batch_rels.add(rel)
709
+
710
+ for i, op in enumerate(links):
711
+ source = (op.get("source") or "").strip()
712
+ doc = (op.get("doc") or "").strip()
713
+ if not source:
714
+ raise IntakeError(f"links[{i}]: source required")
715
+ if not doc:
716
+ raise IntakeError(f"links[{i}]: doc required")
717
+ # Normalize before judging the zone — a LEXICAL split on the raw
718
+ # string ("library/../inbox/evil.md".split("/",1)[0] == "library")
719
+ # passes a path that actually resolves outside every zone. Collapse
720
+ # ".." segments first, then require: no leftover ".." (it climbed
721
+ # above the zone root), not absolute, first segment in DOC_ZONES.
722
+ norm_doc = posixpath.normpath(doc)
723
+ doc_parts = norm_doc.split("/")
724
+ if (posixpath.isabs(norm_doc) or doc_parts[0] == ".."
725
+ or doc_parts[0] not in DOC_ZONES):
726
+ raise IntakeError(
727
+ f"links[{i}]: doc must be zone-relative under "
728
+ f"{sorted(DOC_ZONES)}: {doc!r}")
729
+ op["doc"] = norm_doc # store/compare the NORMALIZED path — later
730
+ # phases (doc-exists check, link()) read
731
+ # this same op dict, never the raw input
732
+ if source not in existing_rels and source not in batch_rels:
733
+ raise IntakeError(
734
+ f"links[{i}]: link source will not exist: {source!r} — "
735
+ "not already registered in sources/ nor admitted by this "
736
+ "batch's own admits")
737
+
738
+ # ── phase 2: execute admits ───────────────────────────────────────────
739
+ admitted = [admit(root, op["file"], op["category"], actor) for op in admits]
740
+
741
+ # ── phase 3: validate every link's doc exists NOW, before any link ───
742
+ for i, op in enumerate(links):
743
+ doc = op["doc"].strip()
744
+ if not (root / doc).is_file():
745
+ raise IntakeError(
746
+ f"links[{i}]: doc does not exist: {doc!r} — write it "
747
+ "(Stage 2 conversion) before linking; no link in this "
748
+ "batch was written")
749
+
750
+ # ── phase 4: execute links ────────────────────────────────────────────
751
+ for op in links:
752
+ link(root, op["source"].strip(), op["doc"].strip())
753
+
754
+ return {"admitted": len(admitted), "linked": len(links)}
755
+
756
+
757
+ def cleanup(run_dir: Path) -> None:
758
+ run_dir = Path(run_dir).resolve()
759
+ parts = run_dir.parts
760
+ if ".intake" not in parts or "workbench" not in parts:
761
+ raise IntakeError(f"refusing to delete outside workbench/.intake: {run_dir}")
762
+ if run_dir.exists():
763
+ shutil.rmtree(run_dir)
764
+
765
+
766
+ # ── CLI ──────────────────────────────────────────────────────────────────
767
+
768
+ def main(argv=None) -> int:
769
+ ap = argparse.ArgumentParser(description="fusion-intake Stage-1 engine")
770
+ sub = ap.add_subparsers(dest="cmd", required=True)
771
+
772
+ p = sub.add_parser("admit", help="inbox -> sources + MANIFEST row")
773
+ p.add_argument("--bucket", required=True)
774
+ p.add_argument("--file", required=True, help="path relative to inbox/")
775
+ p.add_argument("--category", required=True)
776
+ p.add_argument("--actor", required=True)
777
+
778
+ p = sub.add_parser("unpack", help="container -> inbox folder "
779
+ "(vehicle, not an original)")
780
+ p.add_argument("--bucket", required=True)
781
+ p.add_argument("--file", required=True, help="path relative to inbox/")
782
+
783
+ p = sub.add_parser("prepare", help="route + convert / stage for vision")
784
+ p.add_argument("--bucket", required=True)
785
+ p.add_argument("--source", required=True, help="path relative to sources/")
786
+ p.add_argument("--dest", help="zone-relative output dir "
787
+ "(default library/<category>)")
788
+ p.add_argument("--slug")
789
+ p.add_argument("--type", dest="doc_type")
790
+ p.add_argument("--aurora", default="library")
791
+ p.add_argument("--reconcile", action="store_true",
792
+ help="confirmed update: reconcile the existing document "
793
+ "in place instead of refusing the slug collision")
794
+
795
+ p = sub.add_parser("link", help="set the MANIFEST library column")
796
+ p.add_argument("--bucket", required=True)
797
+ p.add_argument("--source", required=True)
798
+ p.add_argument("--doc", required=True)
799
+
800
+ p = sub.add_parser("cleanup", help="delete one work dir")
801
+ p.add_argument("--run-dir", required=True)
802
+
803
+ p = sub.add_parser("batch", help="validated multi-op admit+link in one "
804
+ "process (references/delivery.md)")
805
+ p.add_argument("--bucket", required=True)
806
+ p.add_argument("--ops", required=True,
807
+ help="path to a JSON op-list: {admits: [...], links: [...]}")
808
+ p.add_argument("--actor", default="claude")
809
+
810
+ args = ap.parse_args(argv)
811
+ try:
812
+ if args.cmd == "admit":
813
+ out = admit(Path(args.bucket), args.file, args.category, args.actor)
814
+ elif args.cmd == "unpack":
815
+ out = unpack(Path(args.bucket), args.file)
816
+ elif args.cmd == "prepare":
817
+ out = prepare(Path(args.bucket), args.source, dest=args.dest,
818
+ slug=args.slug, doc_type=args.doc_type,
819
+ aurora=args.aurora, reconcile=args.reconcile)
820
+ elif args.cmd == "link":
821
+ out = link(Path(args.bucket), args.source, args.doc)
822
+ elif args.cmd == "batch":
823
+ ops = json.loads(Path(args.ops).read_text(encoding="utf-8"))
824
+ out = batch(Path(args.bucket), ops, args.actor)
825
+ else:
826
+ cleanup(Path(args.run_dir))
827
+ out = {"cleaned": args.run_dir}
828
+ except IntakeError as exc:
829
+ print(f"intake: {exc}", file=sys.stderr)
830
+ return 1
831
+ print(json.dumps(out, indent=2, ensure_ascii=False))
832
+ return 0
833
+
834
+
835
+ if __name__ == "__main__":
836
+ raise SystemExit(main())