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.
- fusion/__init__.py +3 -0
- fusion/_skills/fusion-analyst/SKILL.md +50 -0
- fusion/_skills/fusion-analyst/references/assess.md +12 -0
- fusion/_skills/fusion-analyst/references/compare.md +12 -0
- fusion/_skills/fusion-analyst/references/export.md +18 -0
- fusion/_skills/fusion-analyst/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-analyst/references/report.md +13 -0
- fusion/_skills/fusion-analyst/scripts/export.py +64 -0
- fusion/_skills/fusion-intake/SKILL.md +128 -0
- fusion/_skills/fusion-intake/references/convert.md +104 -0
- fusion/_skills/fusion-intake/references/delivery.md +107 -0
- fusion/_skills/fusion-intake/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-intake/references/gate.md +107 -0
- fusion/_skills/fusion-intake/scripts/convert.py +836 -0
- fusion/_skills/fusion-intake/scripts/gate.py +267 -0
- fusion/_skills/fusion-librarian/SKILL.md +64 -0
- fusion/_skills/fusion-librarian/references/archive.md +21 -0
- fusion/_skills/fusion-librarian/references/create.md +14 -0
- fusion/_skills/fusion-librarian/references/cross-reference.md +45 -0
- fusion/_skills/fusion-librarian/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-librarian/references/promote.md +24 -0
- fusion/_skills/fusion-librarian/references/query.md +17 -0
- fusion/_skills/fusion-librarian/references/reflect.md +47 -0
- fusion/_skills/fusion-librarian/references/restructure.md +20 -0
- fusion/_skills/fusion-librarian/references/tag.md +13 -0
- fusion/_skills/fusion-librarian/scripts/link-repair.py +371 -0
- fusion/_skills/fusion-planner/SKILL.md +62 -0
- fusion/_skills/fusion-planner/references/close.md +12 -0
- fusion/_skills/fusion-planner/references/create-activity.md +38 -0
- fusion/_skills/fusion-planner/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-planner/references/horizon.md +20 -0
- fusion/bucket.py +77 -0
- fusion/checker.py +248 -0
- fusion/cli.py +406 -0
- fusion/document.py +155 -0
- fusion/hub.py +78 -0
- fusion/indexer.py +75 -0
- fusion/ledger.py +106 -0
- fusion/manifest.py +33 -0
- fusion/scaffold.py +120 -0
- fusion/setup.py +300 -0
- fusion/views.py +111 -0
- fusion_cli-1.1.0.dist-info/METADATA +67 -0
- fusion_cli-1.1.0.dist-info/RECORD +46 -0
- fusion_cli-1.1.0.dist-info/WHEEL +4 -0
- 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
|