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
fusion/bucket.py ADDED
@@ -0,0 +1,77 @@
1
+ """The bucket — zones, identity card, and document iteration (SPEC §2, §3)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Iterator
7
+
8
+ from .document import Document, read_document, split_frontmatter
9
+
10
+ ZONES: tuple[str, ...] = (
11
+ "inbox", "sources", "library", "activities", "workbench", "output",
12
+ )
13
+ DOC_ZONES: tuple[str, ...] = ("library", "activities", "output")
14
+ REQUIRED_BUCKET_FIELDS: tuple[str, ...] = (
15
+ "name", "kind", "description", "fusion_version", "created",
16
+ )
17
+
18
+
19
+ @dataclass
20
+ class Bucket:
21
+ root: Path
22
+ frontmatter: dict | None
23
+ fm_error: str | None
24
+
25
+ def _get(self, key: str):
26
+ return (self.frontmatter or {}).get(key)
27
+
28
+ @property
29
+ def name(self):
30
+ return self._get("name")
31
+
32
+ @property
33
+ def kind(self):
34
+ return self._get("kind")
35
+
36
+ @property
37
+ def description(self):
38
+ return self._get("description")
39
+
40
+ @property
41
+ def inbox_max_age_days(self) -> int:
42
+ value = self._get("inbox_max_age_days")
43
+ try:
44
+ return int(value)
45
+ except (TypeError, ValueError):
46
+ return 7
47
+
48
+
49
+ def find_root(start: Path) -> Path | None:
50
+ current = start.resolve()
51
+ for candidate in (current, *current.parents):
52
+ if (candidate / "BUCKET.md").is_file():
53
+ return candidate
54
+ return None
55
+
56
+
57
+ def load(root: Path) -> Bucket:
58
+ card = root / "BUCKET.md"
59
+ if not card.exists():
60
+ return Bucket(root, None, "BUCKET.md missing")
61
+ fm, err, _ = split_frontmatter(card.read_text(encoding="utf-8"))
62
+ return Bucket(root, fm, err)
63
+
64
+
65
+ def iter_documents(
66
+ root: Path, zones: tuple[str, ...] = DOC_ZONES
67
+ ) -> Iterator[tuple[str, Path, Document]]:
68
+ for zone in zones:
69
+ zone_dir = root / zone
70
+ if not zone_dir.is_dir():
71
+ continue
72
+ for path in sorted(zone_dir.rglob("*.md")):
73
+ rel = path.relative_to(zone_dir)
74
+ if path.name == "INDEX.md" or any(
75
+ part.startswith(".") for part in rel.parts):
76
+ continue
77
+ yield zone, rel, read_document(path)
fusion/checker.py ADDED
@@ -0,0 +1,248 @@
1
+ """fusion check — conformance per SPEC §11. Liberal reader: reports, never raises."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from . import indexer, ledger, manifest
10
+ from .bucket import DOC_ZONES, REQUIRED_BUCKET_FIELDS, ZONES, load, iter_documents
11
+ from .document import AURORAS, FILENAME_RE, MAX_STEM
12
+
13
+ # SPEC §2, §11: output/ MAY hold non-markdown deliverables (exports); their
14
+ # filenames must still be lowercase-hyphen slugs, just with any lowercase
15
+ # extension rather than a hard-coded .md.
16
+ EXPORT_FILENAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+$")
17
+
18
+
19
+ @dataclass
20
+ class Finding:
21
+ level: str # "error" | "warning"
22
+ code: str # E1..E8, W1..W5
23
+ path: str # bucket-relative, "" when bucket-wide
24
+ message: str
25
+
26
+
27
+ def _missing_fields(mapping: dict, fields: tuple[str, ...]) -> list[str]:
28
+ """A required field is missing when absent, None, or blank."""
29
+ return [
30
+ f for f in fields
31
+ if mapping.get(f) is None
32
+ or (isinstance(mapping.get(f), str) and not mapping.get(f).strip())
33
+ ]
34
+
35
+
36
+ def check(root: Path) -> list[Finding]:
37
+ errors: list[Finding] = []
38
+ errors += _e1_zones(root)
39
+ errors += _e2_bucket_card(root)
40
+ errors += _e3_e4_e5_documents(root)
41
+ errors += _e6_ledger_verbs(root)
42
+ errors += _e7_manifest(root)
43
+ errors += _e8_filenames(root)
44
+ warnings = _warnings(root)
45
+ key = lambda f: (f.code, f.path)
46
+ return sorted(errors, key=key) + sorted(warnings, key=key)
47
+
48
+
49
+ def _e1_zones(root: Path) -> list[Finding]:
50
+ return [
51
+ Finding("error", "E1", zone, f"zone missing from bucket root: {zone}/")
52
+ for zone in ZONES
53
+ if not (root / zone).is_dir()
54
+ ]
55
+
56
+
57
+ def _e2_bucket_card(root: Path) -> list[Finding]:
58
+ if not (root / "BUCKET.md").is_file():
59
+ return [Finding("error", "E2", "BUCKET.md", "BUCKET.md missing")]
60
+ b = load(root)
61
+ if b.frontmatter is None:
62
+ return [Finding("error", "E2", "BUCKET.md",
63
+ f"BUCKET.md frontmatter unreadable: {b.fm_error}")]
64
+ return [
65
+ Finding("error", "E2", "BUCKET.md",
66
+ f"BUCKET.md missing required field: {field}")
67
+ for field in _missing_fields(b.frontmatter, REQUIRED_BUCKET_FIELDS)
68
+ ]
69
+
70
+
71
+ def _e3_e4_e5_documents(root: Path) -> list[Finding]:
72
+ findings = []
73
+ for zone, rel, doc in iter_documents(root):
74
+ path = f"{zone}/{rel.as_posix()}"
75
+ if doc.frontmatter is None:
76
+ findings.append(Finding("error", "E3", path,
77
+ f"unreadable frontmatter: {doc.fm_error}"))
78
+ continue
79
+ for field in _missing_fields(doc.frontmatter, ("title", "type", "aurora")):
80
+ findings.append(Finding("error", "E3", path,
81
+ f"missing required field: {field}"))
82
+ aurora = doc.frontmatter.get("aurora")
83
+ blank = isinstance(aurora, str) and not aurora.strip()
84
+ if aurora is not None and not blank and aurora not in AURORAS:
85
+ findings.append(Finding("error", "E4", path,
86
+ f"aurora '{aurora}' is not one of the eight"))
87
+ if not doc.summary_first:
88
+ findings.append(Finding(
89
+ "error", "E5", path,
90
+ "body is not summary-first (## Summary, then a --- line)"))
91
+ return findings
92
+
93
+
94
+ def _e6_ledger_verbs(root: Path) -> list[Finding]:
95
+ return [
96
+ Finding("error", "E6", "LEDGER.md",
97
+ f"unknown verb '{e.verb}' at {e.date} {e.time}")
98
+ for e in ledger.read(root)
99
+ if e.verb not in ledger.VERBS
100
+ ]
101
+
102
+
103
+ def _e7_manifest(root: Path) -> list[Finding]:
104
+ sources = root / "sources"
105
+ if not sources.is_dir():
106
+ return []
107
+ on_disk = {
108
+ p.relative_to(sources).as_posix()
109
+ for p in sources.rglob("*")
110
+ if p.is_file() and p.name != "MANIFEST.md"
111
+ and not any(part.startswith(".")
112
+ for part in p.relative_to(sources).parts)
113
+ }
114
+ rows = {r.file for r in manifest.read(root)}
115
+ findings = [
116
+ Finding("error", "E7", f"sources/{f}",
117
+ f"sources file has no manifest row: {f}")
118
+ for f in sorted(on_disk - rows)
119
+ ]
120
+ findings += [
121
+ Finding("error", "E7", "sources/MANIFEST.md",
122
+ f"manifest row's file is missing or invisible "
123
+ f"(dot-directories are not scanned): {f}")
124
+ for f in sorted(rows - on_disk)
125
+ ]
126
+ return findings
127
+
128
+
129
+ def _e8_filenames(root: Path) -> list[Finding]:
130
+ findings = []
131
+ for zone in DOC_ZONES:
132
+ zone_dir = root / zone
133
+ if not zone_dir.is_dir():
134
+ continue
135
+ for p in sorted(zone_dir.rglob("*")):
136
+ if (not p.is_file() or p.name == "INDEX.md"
137
+ or any(part.startswith(".")
138
+ for part in p.relative_to(zone_dir).parts)):
139
+ continue
140
+ rel = f"{zone}/{p.relative_to(zone_dir).as_posix()}"
141
+ if zone == "output" and p.suffix.lower() != ".md":
142
+ if not EXPORT_FILENAME_RE.match(p.name):
143
+ findings.append(Finding(
144
+ "error", "E8", rel,
145
+ "export filename must be a lowercase-hyphen slug "
146
+ "with a lowercase extension"))
147
+ elif len(p.stem) > MAX_STEM:
148
+ findings.append(Finding(
149
+ "error", "E8", rel,
150
+ f"filename stem exceeds {MAX_STEM} characters"))
151
+ continue
152
+ if not FILENAME_RE.match(p.name):
153
+ findings.append(Finding(
154
+ "error", "E8", rel,
155
+ "filename must be a lowercase-hyphen .md slug"))
156
+ elif len(p.stem) > MAX_STEM:
157
+ findings.append(Finding(
158
+ "error", "E8", rel,
159
+ f"filename stem exceeds {MAX_STEM} characters"))
160
+ return findings
161
+
162
+
163
+ def _warnings(root: Path) -> list[Finding]:
164
+ findings: list[Finding] = []
165
+ findings += _w1_stale_inbox(root)
166
+ findings += _w2_indexes(root)
167
+ findings += _w3_w4_documents(root)
168
+ findings += _w5_untouched_activities(root)
169
+ return findings
170
+
171
+
172
+ def _w1_stale_inbox(root: Path) -> list[Finding]:
173
+ inbox = root / "inbox"
174
+ if not inbox.is_dir():
175
+ return []
176
+ max_age = load(root).inbox_max_age_days
177
+ cutoff = time.time() - max_age * 86400
178
+ return [
179
+ Finding("warning", "W1", f"inbox/{p.relative_to(inbox).as_posix()}",
180
+ f"inbox file older than {max_age} days")
181
+ for p in sorted(inbox.rglob("*"))
182
+ if p.is_file() and not p.name.startswith(".")
183
+ and p.stat().st_mtime < cutoff
184
+ ]
185
+
186
+
187
+ def _w2_indexes(root: Path) -> list[Finding]:
188
+ findings = []
189
+ for zone in indexer.INDEXED_ZONES:
190
+ zone_dir = root / zone
191
+ if not zone_dir.is_dir():
192
+ continue
193
+ index_path = zone_dir / "INDEX.md"
194
+ rel = f"{zone}/INDEX.md"
195
+ if not index_path.exists():
196
+ findings.append(Finding("warning", "W2", rel, "INDEX.md missing"))
197
+ elif index_path.read_bytes() != indexer.generate(zone_dir, zone).encode("utf-8"):
198
+ findings.append(Finding("warning", "W2", rel,
199
+ "INDEX.md stale — regeneration differs"))
200
+ return findings
201
+
202
+
203
+ def _w3_w4_documents(root: Path) -> list[Finding]:
204
+ findings = []
205
+ for zone, rel, doc in iter_documents(root):
206
+ path = f"{zone}/{rel.as_posix()}"
207
+ on_archive_path = "archive" in rel.parts
208
+ is_archive_aurora = doc.aurora == "archive"
209
+ if on_archive_path and not is_archive_aurora:
210
+ findings.append(Finding("warning", "W3", path,
211
+ "archived path without aurora: archive"))
212
+ elif is_archive_aurora and not on_archive_path:
213
+ findings.append(Finding("warning", "W3", path,
214
+ "aurora: archive outside an archive/ path"))
215
+ doc_dir = (root / zone / rel).parent
216
+ for link in doc.links:
217
+ target = link.split("#", 1)[0]
218
+ if not target:
219
+ continue
220
+ if not (doc_dir / target).resolve().exists():
221
+ findings.append(Finding("warning", "W4", path,
222
+ f"broken relative link: {link}"))
223
+ return findings
224
+
225
+
226
+ def _w5_untouched_activities(root: Path) -> list[Finding]:
227
+ entries = ledger.read(root)
228
+ reflections = [i for i, e in enumerate(entries) if e.verb == "reflected"]
229
+ if not reflections:
230
+ return []
231
+ start = reflections[-2] + 1 if len(reflections) >= 2 else 0
232
+ findings = []
233
+ for zone, rel, doc in iter_documents(root, zones=("activities",)):
234
+ if doc.status != "active":
235
+ continue
236
+ doc_path = f"activities/{rel.as_posix()}"
237
+ activity_dir = f"activities/{rel.parent.as_posix()}/"
238
+ mentions = [i for i, e in enumerate(entries)
239
+ if doc_path in e.obj or activity_dir in e.obj]
240
+ if any(start <= i < reflections[-1] for i in mentions):
241
+ continue
242
+ if mentions and mentions[0] > reflections[-1]:
243
+ continue # born after the reflection — not yet through a window
244
+ findings.append(Finding(
245
+ "warning", "W5", doc_path,
246
+ "active activity with no ledger mention across the last "
247
+ "reflection window"))
248
+ return findings