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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# tag — bulk metadata, honestly reported
|
|
2
|
+
|
|
3
|
+
1. Parse the field and value; `--scope`-like constraints (a folder, a
|
|
4
|
+
type) narrow the set. Value "auto" means you infer per document from
|
|
5
|
+
its content — say so in the report.
|
|
6
|
+
2. Edit each document's frontmatter, preserving every existing key and
|
|
7
|
+
the body untouched. `updated:` does NOT bump (metadata, not content).
|
|
8
|
+
3. Report a table: file · field · old → new.
|
|
9
|
+
4. One ledger entry for the batch:
|
|
10
|
+
`fusion log classified "<scope> — <field>: <value> (<N> documents)"
|
|
11
|
+
--bucket <root> --as <you>` then `fusion index <root>` (summaries
|
|
12
|
+
unchanged, but titles/auroras may have moved in the index) and
|
|
13
|
+
`fusion check <root>`.
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "PyYAML>=6.0",
|
|
6
|
+
# ]
|
|
7
|
+
# ///
|
|
8
|
+
"""fusion-librarian link repair — scan proposes, the human approves, apply
|
|
9
|
+
signs off.
|
|
10
|
+
|
|
11
|
+
Productizes the pattern run by hand during the 2026-07-10 delivery: 67
|
|
12
|
+
broken relative links repaired across two passes on a real bucket
|
|
13
|
+
(docs/dogfood/frictions.md row 7). `scan` never writes — it walks
|
|
14
|
+
library/ and activities/, finds relative links whose target does not
|
|
15
|
+
exist from the doc's own directory, and proposes a repair by basename
|
|
16
|
+
match against sources/ + the doc zones: a UNIQUE basename match is
|
|
17
|
+
`exact`; a unique match only after lowercase/hyphen-insensitive
|
|
18
|
+
normalization is `fuzzy` — never silently folded into an exact batch;
|
|
19
|
+
two or more candidates is `unrepairable` (never guess between them).
|
|
20
|
+
|
|
21
|
+
`apply` rewrites ONLY the pairs the human hands back in an approved
|
|
22
|
+
proposals file, validating every pair (doc inside library/ or
|
|
23
|
+
activities/, target exists, no path escapes the bucket) BEFORE writing
|
|
24
|
+
any of them — the intake gate's validate-before-damage discipline. It
|
|
25
|
+
writes no ledger entry: the gear's protocol (references/cross-reference.md)
|
|
26
|
+
has the operator sign one `noted` for the pass and re-run `fusion
|
|
27
|
+
index`/`fusion check`.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
uv run link-repair.py scan --bucket <bucket-root> > proposals.json
|
|
31
|
+
uv run link-repair.py apply --bucket <bucket-root> --proposals <file.json>
|
|
32
|
+
"""
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
import yaml
|
|
42
|
+
|
|
43
|
+
TODAY = datetime.now().strftime("%Y-%m-%d")
|
|
44
|
+
|
|
45
|
+
# Documents that can carry broken links worth repairing — never output/
|
|
46
|
+
# (deliverables are frozen) and never a zone outside the doc format.
|
|
47
|
+
DOC_ZONES = ("library", "activities")
|
|
48
|
+
|
|
49
|
+
# Where a repair candidate may be found: the doc zones themselves (a link
|
|
50
|
+
# that drifted to a sibling that moved) plus sources/ (the assets/X ->
|
|
51
|
+
# sources/... pattern proven live).
|
|
52
|
+
SEARCH_ZONES = ("sources", "library", "activities")
|
|
53
|
+
|
|
54
|
+
# Registers with a single writer elsewhere — never a link-repair target
|
|
55
|
+
# or a document to scan for links.
|
|
56
|
+
SKIP_NAMES = {"MANIFEST.md", "INDEX.md"}
|
|
57
|
+
|
|
58
|
+
# `](target)` — target's first char excludes ')' and '#' so bare anchor
|
|
59
|
+
# links ([x](#heading)) never match; http(s)/mailto are filtered after,
|
|
60
|
+
# by scheme, since they share the same bracket-paren shape.
|
|
61
|
+
LINK_RE = re.compile(r"\]\(([^)#][^)]*)\)")
|
|
62
|
+
_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
|
|
63
|
+
|
|
64
|
+
# Code is not prose: fenced blocks and inline code spans never carry a
|
|
65
|
+
# real relative link, so they're blanked out before extraction — the
|
|
66
|
+
# same fix as cli/src/fusion/document.py's `_blank_code` (twin
|
|
67
|
+
# implementation, kept independent so this skill stays self-contained).
|
|
68
|
+
_FENCE_OPEN_RE = re.compile(r"^(\s*)(`{3,}|~{3,})")
|
|
69
|
+
_INLINE_CODE_RE = re.compile(r"(`+)((?:(?!\1)[^\n])*?)\1")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _blank_code(body: str) -> str:
|
|
73
|
+
"""Blank fenced blocks (opener to closer inclusive, unterminated
|
|
74
|
+
fences swallow to EOF) and single-line inline code spans, replacing
|
|
75
|
+
each with equal-length whitespace so link extraction never reads
|
|
76
|
+
code as prose. Inline spans use a run of N backticks as delimiter
|
|
77
|
+
(CommonMark's literal-backtick idiom, e.g.
|
|
78
|
+
`` `` `code with ` backtick` `` ``), closing only on the next run of
|
|
79
|
+
the SAME length, so a shorter backtick run inside the span is just
|
|
80
|
+
content, not a terminator."""
|
|
81
|
+
lines = body.split("\n")
|
|
82
|
+
out = []
|
|
83
|
+
i = 0
|
|
84
|
+
n = len(lines)
|
|
85
|
+
while i < n:
|
|
86
|
+
opener = _FENCE_OPEN_RE.match(lines[i])
|
|
87
|
+
if not opener:
|
|
88
|
+
out.append(lines[i])
|
|
89
|
+
i += 1
|
|
90
|
+
continue
|
|
91
|
+
fence_char, fence_len = opener.group(2)[0], len(opener.group(2))
|
|
92
|
+
closer_re = re.compile(
|
|
93
|
+
r"^\s*" + re.escape(fence_char) + "{" + str(fence_len) + ",}\\s*$"
|
|
94
|
+
)
|
|
95
|
+
out.append(" " * len(lines[i]))
|
|
96
|
+
i += 1
|
|
97
|
+
while i < n:
|
|
98
|
+
out.append(" " * len(lines[i]))
|
|
99
|
+
closed = bool(closer_re.match(lines[i]))
|
|
100
|
+
i += 1
|
|
101
|
+
if closed:
|
|
102
|
+
break
|
|
103
|
+
blanked = "\n".join(out)
|
|
104
|
+
return _INLINE_CODE_RE.sub(lambda m: " " * len(m.group(0)), blanked)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RepairError(Exception):
|
|
108
|
+
"""Strict-writer refusal — named loudly, never silently skipped."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── discovery ────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def _iter_files(zone_dir: Path):
|
|
114
|
+
for p in sorted(zone_dir.rglob("*")):
|
|
115
|
+
if not p.is_file() or p.name in SKIP_NAMES:
|
|
116
|
+
continue
|
|
117
|
+
rel = p.relative_to(zone_dir)
|
|
118
|
+
if any(part.startswith(".") for part in rel.parts):
|
|
119
|
+
continue
|
|
120
|
+
yield p
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _iter_docs(root: Path):
|
|
124
|
+
"""Every .md in library/ and activities/, skipping dot-dirs and the
|
|
125
|
+
generated INDEX.md — hand-editing a register is never on the table."""
|
|
126
|
+
for zone in DOC_ZONES:
|
|
127
|
+
zone_dir = root / zone
|
|
128
|
+
if not zone_dir.is_dir():
|
|
129
|
+
continue
|
|
130
|
+
for p in _iter_files(zone_dir):
|
|
131
|
+
if p.suffix.lower() == ".md":
|
|
132
|
+
yield p
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _normalize(name: str) -> str:
|
|
136
|
+
"""Lowercase, hyphen-insensitive basename key: case differences and
|
|
137
|
+
hyphen placement collapse to the same key (amua-map.png and
|
|
138
|
+
a-mua-map.png both key to 'amuamap.png') — a fuzzy match, extension
|
|
139
|
+
still required so unrelated files never collide."""
|
|
140
|
+
stem = Path(name).stem.lower().replace("-", "")
|
|
141
|
+
ext = Path(name).suffix.lower()
|
|
142
|
+
return f"{stem}{ext}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _index_candidates(root: Path):
|
|
146
|
+
"""basename -> [bucket-relative paths] and normalized-basename ->
|
|
147
|
+
[...] over sources/ + the doc zones — the search space link-repair
|
|
148
|
+
is allowed to propose targets from."""
|
|
149
|
+
exact: dict[str, list[Path]] = {}
|
|
150
|
+
normalized: dict[str, list[Path]] = {}
|
|
151
|
+
for zone in SEARCH_ZONES:
|
|
152
|
+
zone_dir = root / zone
|
|
153
|
+
if not zone_dir.is_dir():
|
|
154
|
+
continue
|
|
155
|
+
for p in _iter_files(zone_dir):
|
|
156
|
+
rel = p.relative_to(root)
|
|
157
|
+
exact.setdefault(p.name, []).append(rel)
|
|
158
|
+
normalized.setdefault(_normalize(p.name), []).append(rel)
|
|
159
|
+
return exact, normalized
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _extract_links(text: str):
|
|
163
|
+
"""Yield (raw_target, path_part, anchor) for every candidate relative
|
|
164
|
+
link — http(s)/mailto skipped by scheme, anchor-only already excluded
|
|
165
|
+
by LINK_RE's negative first-char class, and fenced/inline code
|
|
166
|
+
blanked first so code is never read as prose."""
|
|
167
|
+
for m in LINK_RE.finditer(_blank_code(text)):
|
|
168
|
+
target = m.group(1)
|
|
169
|
+
if _SCHEME_RE.match(target):
|
|
170
|
+
continue
|
|
171
|
+
path_part, _, anchor = target.partition("#")
|
|
172
|
+
if not path_part:
|
|
173
|
+
continue
|
|
174
|
+
yield target, path_part, anchor
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ── scan ─────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
def scan(root: Path) -> dict:
|
|
180
|
+
root = Path(root)
|
|
181
|
+
exact_idx, normalized_idx = _index_candidates(root)
|
|
182
|
+
proposals, unrepairable = [], []
|
|
183
|
+
|
|
184
|
+
for doc in _iter_docs(root):
|
|
185
|
+
doc_rel = doc.relative_to(root).as_posix()
|
|
186
|
+
text = doc.read_text(encoding="utf-8", errors="replace")
|
|
187
|
+
for raw_target, path_part, anchor in _extract_links(text):
|
|
188
|
+
if (doc.parent / path_part).exists():
|
|
189
|
+
continue # not broken
|
|
190
|
+
|
|
191
|
+
basename = Path(path_part).name
|
|
192
|
+
candidates = exact_idx.get(basename, [])
|
|
193
|
+
confidence = None
|
|
194
|
+
chosen = None
|
|
195
|
+
if len(candidates) == 1:
|
|
196
|
+
confidence, chosen = "exact", candidates[0]
|
|
197
|
+
else:
|
|
198
|
+
norm_candidates = normalized_idx.get(_normalize(basename), [])
|
|
199
|
+
if len(norm_candidates) == 1:
|
|
200
|
+
confidence, chosen = "fuzzy", norm_candidates[0]
|
|
201
|
+
|
|
202
|
+
if chosen is None:
|
|
203
|
+
unrepairable.append({"doc": doc_rel, "link": raw_target})
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
new_path = os.path.relpath(root / chosen, start=doc.parent)
|
|
207
|
+
target = new_path.replace(os.sep, "/") + (f"#{anchor}" if anchor else "")
|
|
208
|
+
proposals.append({
|
|
209
|
+
"doc": doc_rel, "link": raw_target,
|
|
210
|
+
"target": target, "confidence": confidence,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return {"proposals": proposals, "unrepairable": unrepairable}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _print_table(result: dict) -> None:
|
|
217
|
+
exact = [p for p in result["proposals"] if p["confidence"] == "exact"]
|
|
218
|
+
fuzzy = [p for p in result["proposals"] if p["confidence"] == "fuzzy"]
|
|
219
|
+
|
|
220
|
+
def _rows(items):
|
|
221
|
+
for p in items:
|
|
222
|
+
print(f" {p['doc']}: {p['link']} -> {p['target']}", file=sys.stderr)
|
|
223
|
+
|
|
224
|
+
print(f"EXACT ({len(exact)}) — safe to apply as-is", file=sys.stderr)
|
|
225
|
+
_rows(exact)
|
|
226
|
+
print(f"\nFUZZY ({len(fuzzy)}) — normalized match only, "
|
|
227
|
+
"confirm per group before applying", file=sys.stderr)
|
|
228
|
+
_rows(fuzzy)
|
|
229
|
+
print(f"\nUNREPAIRABLE ({len(result['unrepairable'])}) — "
|
|
230
|
+
"ambiguous or no candidate, never guessed", file=sys.stderr)
|
|
231
|
+
for u in result["unrepairable"]:
|
|
232
|
+
print(f" {u['doc']}: {u['link']}", file=sys.stderr)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ── apply ────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
def _split_frontmatter(text: str, doc_rel: str):
|
|
238
|
+
if not text.startswith("---\n"):
|
|
239
|
+
raise RepairError(f"no frontmatter block: {doc_rel}")
|
|
240
|
+
try:
|
|
241
|
+
end = text.index("\n---", 4)
|
|
242
|
+
except ValueError:
|
|
243
|
+
raise RepairError(f"unterminated frontmatter block: {doc_rel}") from None
|
|
244
|
+
fm = yaml.safe_load(text[4:end])
|
|
245
|
+
if not isinstance(fm, dict):
|
|
246
|
+
raise RepairError(f"frontmatter is not a mapping: {doc_rel}")
|
|
247
|
+
return fm, text[end + 4:]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _bump_updated(text: str, doc_rel: str) -> str:
|
|
251
|
+
fm, rest = _split_frontmatter(text, doc_rel)
|
|
252
|
+
fm["updated"] = TODAY
|
|
253
|
+
front = yaml.safe_dump(fm, sort_keys=False, allow_unicode=True,
|
|
254
|
+
width=2**31 - 1)
|
|
255
|
+
return f"---\n{front}---{rest}"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def apply_proposals(root: Path, proposals: list) -> int:
|
|
259
|
+
"""Rewrite ONLY the given (doc, link) -> target pairs. Validates every
|
|
260
|
+
pair — doc under library/ or activities/, doc not a single-writer
|
|
261
|
+
register (SKIP_NAMES), doc exists, the literal `](link)` text is
|
|
262
|
+
present, target resolves inside the bucket and exists on disk, AND the
|
|
263
|
+
doc's frontmatter parses — BEFORE writing any file
|
|
264
|
+
(validate-all-before-damage, the intake gate's rule applied here). The
|
|
265
|
+
frontmatter check runs in phase 1 specifically so a later doc's
|
|
266
|
+
malformed frontmatter can never be discovered mid-write, after an
|
|
267
|
+
earlier doc in the same batch was already rewritten: phase 2's own
|
|
268
|
+
_bump_updated call stays as a safety net, but by construction it can
|
|
269
|
+
no longer raise."""
|
|
270
|
+
root = Path(root).resolve()
|
|
271
|
+
zone_roots = {zone: (root / zone).resolve() for zone in DOC_ZONES}
|
|
272
|
+
|
|
273
|
+
# ── phase 1: validate every pair, read but never write ───────────────
|
|
274
|
+
plan: dict[Path, list[tuple[str, str]]] = {}
|
|
275
|
+
doc_texts: dict[Path, str] = {}
|
|
276
|
+
frontmatter_checked: set[Path] = set()
|
|
277
|
+
for i, p in enumerate(proposals):
|
|
278
|
+
doc_rel = p["doc"]
|
|
279
|
+
link = p["link"]
|
|
280
|
+
target = p["target"]
|
|
281
|
+
doc_path = (root / doc_rel).resolve()
|
|
282
|
+
if doc_path.name in SKIP_NAMES:
|
|
283
|
+
raise RepairError(
|
|
284
|
+
f"proposals[{i}]: refuses a single-writer register, "
|
|
285
|
+
f"never a repair target (skip: {doc_rel!r})")
|
|
286
|
+
if not any(doc_path.is_relative_to(z) for z in zone_roots.values()):
|
|
287
|
+
raise RepairError(
|
|
288
|
+
f"proposals[{i}]: doc outside library/ or activities/: "
|
|
289
|
+
f"{doc_rel!r}")
|
|
290
|
+
if not doc_path.is_file():
|
|
291
|
+
raise RepairError(f"proposals[{i}]: doc does not exist: {doc_rel!r}")
|
|
292
|
+
|
|
293
|
+
target_path_part, _, _ = target.partition("#")
|
|
294
|
+
target_abs = (doc_path.parent / target_path_part).resolve()
|
|
295
|
+
if not target_abs.is_relative_to(root):
|
|
296
|
+
raise RepairError(
|
|
297
|
+
f"proposals[{i}]: target escapes the bucket: {target!r}")
|
|
298
|
+
if not target_abs.is_file():
|
|
299
|
+
raise RepairError(
|
|
300
|
+
f"proposals[{i}]: target does not exist: {target!r} "
|
|
301
|
+
f"(doc {doc_rel!r})")
|
|
302
|
+
|
|
303
|
+
text = doc_texts.setdefault(doc_path, doc_path.read_text(encoding="utf-8"))
|
|
304
|
+
if f"]({link})" not in text:
|
|
305
|
+
raise RepairError(
|
|
306
|
+
f"proposals[{i}]: link text not found verbatim in "
|
|
307
|
+
f"{doc_rel!r}: {link!r}")
|
|
308
|
+
|
|
309
|
+
if doc_path not in frontmatter_checked:
|
|
310
|
+
# Raises RepairError (unparseable/missing frontmatter) before
|
|
311
|
+
# any document in this batch has been written — the same
|
|
312
|
+
# parse _bump_updated would otherwise only attempt in phase 2,
|
|
313
|
+
# by which point earlier docs could already be on disk.
|
|
314
|
+
_split_frontmatter(text, doc_rel)
|
|
315
|
+
frontmatter_checked.add(doc_path)
|
|
316
|
+
|
|
317
|
+
plan.setdefault(doc_path, []).append((link, target))
|
|
318
|
+
|
|
319
|
+
# ── phase 2: execute — every pair already validated ───────────────────
|
|
320
|
+
changed = 0
|
|
321
|
+
for doc_path, edits in plan.items():
|
|
322
|
+
rel = doc_path.relative_to(root).as_posix()
|
|
323
|
+
text = doc_texts[doc_path]
|
|
324
|
+
new_text = text
|
|
325
|
+
for link, target in edits:
|
|
326
|
+
new_text = new_text.replace(f"]({link})", f"]({target})")
|
|
327
|
+
if new_text != text:
|
|
328
|
+
new_text = _bump_updated(new_text, rel)
|
|
329
|
+
doc_path.write_text(new_text, encoding="utf-8", newline="\n")
|
|
330
|
+
changed += 1
|
|
331
|
+
return changed
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ── CLI ──────────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
def main(argv=None) -> int:
|
|
337
|
+
ap = argparse.ArgumentParser(
|
|
338
|
+
description="fusion-librarian link-repair: scan / apply")
|
|
339
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
340
|
+
|
|
341
|
+
p = sub.add_parser("scan", help="propose repairs for broken relative "
|
|
342
|
+
"links in library/ and activities/")
|
|
343
|
+
p.add_argument("--bucket", required=True)
|
|
344
|
+
|
|
345
|
+
p = sub.add_parser("apply", help="apply an approved subset of a "
|
|
346
|
+
"scan's proposals")
|
|
347
|
+
p.add_argument("--bucket", required=True)
|
|
348
|
+
p.add_argument("--proposals", required=True,
|
|
349
|
+
help="JSON file: {\"proposals\": [...]} or a bare list")
|
|
350
|
+
|
|
351
|
+
args = ap.parse_args(argv)
|
|
352
|
+
root = Path(args.bucket).expanduser()
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
if args.cmd == "scan":
|
|
356
|
+
result = scan(root)
|
|
357
|
+
_print_table(result)
|
|
358
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
359
|
+
else:
|
|
360
|
+
payload = json.loads(Path(args.proposals).read_text(encoding="utf-8"))
|
|
361
|
+
proposals = payload["proposals"] if isinstance(payload, dict) else payload
|
|
362
|
+
changed = apply_proposals(root, proposals)
|
|
363
|
+
print(json.dumps({"changed": changed}, indent=2, ensure_ascii=False))
|
|
364
|
+
except RepairError as exc:
|
|
365
|
+
print(f"link-repair: {exc}", file=sys.stderr)
|
|
366
|
+
return 1
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == "__main__":
|
|
371
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fusion-planner
|
|
3
|
+
description: "The Fusion planner — the horizon keeper. The human drives activities; the planner structures them. Three gears: create-activity (a new activity folder with a stateful plan document — works for plans, campaigns, programs, agendas alike), horizon (review what's live: stalled activities, expiring commitments, what fusion today will show tomorrow), close (finish an activity: status done, archived, out of the way). Use for 'new project/activity/campaign', 'plan this', 'what's on my plate', 'review my activities', 'what's stalled', 'agenda', 'close/finish this activity'. For placing knowledge use fusion-librarian; for deliverables use fusion-analyst. Applies only inside a Fusion bucket — a directory tree with BUCKET.md and LEDGER.md at its root; if there is no such bucket in play, this skill does not apply."
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: "Requires the fusion CLI on PATH."
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# fusion-planner — the horizon
|
|
9
|
+
|
|
10
|
+
Activities are live work: folders under `activities/`, stateful documents,
|
|
11
|
+
honest statuses. The planner keeps the horizon true — what `fusion today`
|
|
12
|
+
composes tomorrow morning is this skill's report card.
|
|
13
|
+
|
|
14
|
+
Read `references/fusion-conventions.md` once per session; read the
|
|
15
|
+
bucket's `BUCKET.md ## Conventions` before acting. No `BUCKET.md` up the
|
|
16
|
+
tree and none named? Stop — this is not a Fusion bucket, and no Fusion
|
|
17
|
+
skill applies (`fusion hub` lists the real ones).
|
|
18
|
+
|
|
19
|
+
## Pick the gear
|
|
20
|
+
|
|
21
|
+
| Signal | Gear | Load |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| new activity / plan / campaign / program / agenda | create-activity | references/create-activity.md |
|
|
24
|
+
| what's live / stalled / on my plate / review activities (default) | horizon | references/horizon.md |
|
|
25
|
+
| close / finish / wrap up an activity | close | references/close.md |
|
|
26
|
+
|
|
27
|
+
The default is **horizon** — read-mostly: it reports freely and writes
|
|
28
|
+
only on a yes. close moves files; reached by inference, it stops and
|
|
29
|
+
confirms.
|
|
30
|
+
|
|
31
|
+
## The activity shape
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
activities/<slug>/
|
|
35
|
+
├── plan.md # the stateful heart: status, dates, aurora
|
|
36
|
+
└── … # notes, briefs — documents, same rules
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`plan.md` frontmatter: the three required fields, plus `status:`
|
|
40
|
+
(`active` | `done` | `dormant`) and `due:` (ISO date) when a real deadline
|
|
41
|
+
exists. Aurora speaks for attention: `commitments` when promised,
|
|
42
|
+
`focus` when it is the deep work, `collab` when shared, `explore` when
|
|
43
|
+
tentative. Expandability is the shape, not machinery: a campaign is an
|
|
44
|
+
activity whose plan has phases; an agenda is an activity whose plan is
|
|
45
|
+
dated items.
|
|
46
|
+
|
|
47
|
+
## Ledger discipline
|
|
48
|
+
|
|
49
|
+
| Act | Verb |
|
|
50
|
+
|---|---|
|
|
51
|
+
| new activity | `created` |
|
|
52
|
+
| status change (active ⇄ dormant, honest corrections) | `noted` with the change in the note |
|
|
53
|
+
| close | `archived` |
|
|
54
|
+
|
|
55
|
+
Always `--bucket <root> --as <you>`, then `fusion index <root>` and
|
|
56
|
+
`fusion check <root>` green.
|
|
57
|
+
|
|
58
|
+
## Never
|
|
59
|
+
|
|
60
|
+
- Never let a dead activity keep `status: active` — the horizon lies.
|
|
61
|
+
- Never set `due:` the human didn't state or clearly imply.
|
|
62
|
+
- Never hand-edit the registers.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# close — done, kept, out of the way
|
|
2
|
+
|
|
3
|
+
1. Confirm the target activity and that the human means finished (or a
|
|
4
|
+
Delegation covers routine closes). State the plan in one sentence.
|
|
5
|
+
2. Edit `plan.md` (and any sibling documents): `status: done`,
|
|
6
|
+
`aurora: archive`. Add a final Log line summarizing the outcome.
|
|
7
|
+
3. Move the whole folder to `activities/archive/<slug>/`.
|
|
8
|
+
4. `fusion log archived "activities/<slug>/ → activities/archive/<slug>/"
|
|
9
|
+
--bucket <root> --as <you>` · `fusion index <root>` ·
|
|
10
|
+
`fusion check <root>` green — path and aurora agree or W3 objects.
|
|
11
|
+
5. If deliverables shipped from this activity, remind the human where
|
|
12
|
+
they live in `output/` (the analyst's ledger entries say).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# create-activity — structure the human's intent
|
|
2
|
+
|
|
3
|
+
1. Get the essentials from the conversation: name (→ slug), what done
|
|
4
|
+
looks like, deadline (if promised), who else is involved. Ask only
|
|
5
|
+
what the human hasn't said.
|
|
6
|
+
2. Choose aurora: promised-with-deadline → `commitments`; the current
|
|
7
|
+
deep work → `focus`; shared → `collab`; tentative → `explore`. Say
|
|
8
|
+
which and why in one line.
|
|
9
|
+
3. Create `activities/<slug>/plan.md`:
|
|
10
|
+
|
|
11
|
+
```markdown
|
|
12
|
+
---
|
|
13
|
+
title: <name>
|
|
14
|
+
type: plan
|
|
15
|
+
aurora: <chosen>
|
|
16
|
+
status: active
|
|
17
|
+
created: <today>
|
|
18
|
+
due: <date, only if real>
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Summary
|
|
22
|
+
|
|
23
|
+
<what this activity is and what done looks like, 2–3 lines>
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Steps
|
|
28
|
+
|
|
29
|
+
- [ ] <the first concrete steps, from the conversation>
|
|
30
|
+
|
|
31
|
+
## Log
|
|
32
|
+
|
|
33
|
+
- <today> — created.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
4. `fusion log created "activities/<slug>/plan.md" --bucket <root>
|
|
37
|
+
--as <you>` · `fusion index <root>` · `fusion check <root>` green.
|
|
38
|
+
Then show the human what `fusion today` now includes.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# The Fusion Conventions — operator's card
|
|
2
|
+
|
|
3
|
+
> Carried byte-identical by all four Fusion skills. SPEC.md in the Fusion
|
|
4
|
+
> repository is normative; this card is the working summary. When they
|
|
5
|
+
> disagree, SPEC.md wins and this card has a bug.
|
|
6
|
+
|
|
7
|
+
**The contract:** the human judges, the AI operates, the files remember.
|
|
8
|
+
|
|
9
|
+
**Liberal reader, strict writer.** Never refuse to read a bucket because
|
|
10
|
+
something is missing or unknown. Never write into `library/`, `activities/`,
|
|
11
|
+
`output/`, or a register without satisfying every rule below first.
|
|
12
|
+
|
|
13
|
+
## The bucket
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
<bucket>/
|
|
17
|
+
├── BUCKET.md # identity card + learned conventions
|
|
18
|
+
├── LEDGER.md # append-only collaboration record
|
|
19
|
+
├── inbox/ # drop zone — things arrive, nothing lives here
|
|
20
|
+
├── sources/ # immutable originals + MANIFEST.md
|
|
21
|
+
├── library/ # settled knowledge — documents
|
|
22
|
+
├── activities/ # live work — documents + status
|
|
23
|
+
├── workbench/ # ephemeral human+AI space — NO format rules
|
|
24
|
+
└── output/ # finished deliverables — documents
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- `sources/` is immutable: never modify, rename, or delete a registered file.
|
|
28
|
+
- `workbench/` has no rules; leaving it (promotion) is a deliberate,
|
|
29
|
+
ledger-logged act.
|
|
30
|
+
- Fusion holds knowledge and work, never media or code — documents point
|
|
31
|
+
(`resource:`) at big things, they never swallow them.
|
|
32
|
+
- `output/` may also hold non-markdown deliverable files (exports); their
|
|
33
|
+
names are still lowercase-hyphen slugs with a lowercase extension.
|
|
34
|
+
|
|
35
|
+
## Before acting — always
|
|
36
|
+
|
|
37
|
+
1. Read `BUCKET.md`: the identity card, then `## Conventions` — `### Rules`
|
|
38
|
+
are how this bucket works; `### Delegations` are your standing autonomy
|
|
39
|
+
grants. They bind you.
|
|
40
|
+
2. Triage through `library/INDEX.md` and `activities/INDEX.md` plus document
|
|
41
|
+
summaries before opening bodies.
|
|
42
|
+
|
|
43
|
+
## The document format
|
|
44
|
+
|
|
45
|
+
Every `.md` in `library/`, `activities/`, `output/` (except INDEX.md):
|
|
46
|
+
|
|
47
|
+
```markdown
|
|
48
|
+
---
|
|
49
|
+
title: Human-readable name
|
|
50
|
+
type: what-it-is # open vocabulary, curated per bucket
|
|
51
|
+
aurora: library # one of the eight — closed set
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Summary
|
|
55
|
+
|
|
56
|
+
Two or three lines a human or agent reads in two seconds.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
Full body. Cross-links are plain relative markdown links.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- Required: `title`, `type`, `aurora` — exactly three.
|
|
64
|
+
- Optional: `tags`, `created`/`updated` (ISO dates), `due` (ISO date the
|
|
65
|
+
thing falls due — `fusion agenda` surfaces it), `source` (path into
|
|
66
|
+
`sources/`), `resource` (URI or bucket path of the thing this document
|
|
67
|
+
describes), `status` (`active`|`done`|`dormant`, activities only),
|
|
68
|
+
`data_sources` (paths list, output only).
|
|
69
|
+
- Body MUST be summary-first: `## Summary`, the lines, a `---` separator,
|
|
70
|
+
then everything else.
|
|
71
|
+
- Filenames: lowercase, hyphen-separated, `.md`, stem ≤60 chars.
|
|
72
|
+
- Preserve frontmatter keys you don't recognize.
|
|
73
|
+
|
|
74
|
+
## Aurora — the eight (closed)
|
|
75
|
+
|
|
76
|
+
| Aurora | Meaning |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `commitments` | Obligations, promises, deadlines |
|
|
79
|
+
| `focus` | Deep work, what deserves full attention |
|
|
80
|
+
| `ops` | Operations, process, the recurring |
|
|
81
|
+
| `collab` | Shared work, other people involved |
|
|
82
|
+
| `life` | Personal, wellbeing, the non-work |
|
|
83
|
+
| `explore` | Curiosity, research, the not-yet-settled |
|
|
84
|
+
| `archive` | Done, kept, out of the way |
|
|
85
|
+
| `library` | Reference, the settled knowledge |
|
|
86
|
+
|
|
87
|
+
Aurora says what a document means for the human's attention — never invent
|
|
88
|
+
a ninth value.
|
|
89
|
+
|
|
90
|
+
## Archive
|
|
91
|
+
|
|
92
|
+
No archive zone: archived items move to an `archive/` subfolder inside their
|
|
93
|
+
zone AND take `aurora: archive`. Path is the truth, aurora is the signal —
|
|
94
|
+
both, always.
|
|
95
|
+
|
|
96
|
+
## The registers — single writers, no exceptions
|
|
97
|
+
|
|
98
|
+
| File | Only writer | Your move |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `LEDGER.md` | `fusion log` | `fusion log <verb> "<object>" [--note "…"] --as <you>` |
|
|
101
|
+
| `library/INDEX.md`, `activities/INDEX.md` | `fusion index` | run it after any add/move/edit that changes titles or summaries |
|
|
102
|
+
| `sources/MANIFEST.md` | fusion-intake's `scripts/convert.py` | everything enters `sources/` through the intake gate |
|
|
103
|
+
|
|
104
|
+
Never edit these three files by hand — not with an editor tool, not with
|
|
105
|
+
shell. The ledger verbs (closed set of eleven): `created`, `converted`,
|
|
106
|
+
`classified`, `indexed`, `moved`, `promoted`, `archived`, `restructured`,
|
|
107
|
+
`shipped`, `reflected`, `noted`. Sign with your agent name (`--as claude`,
|
|
108
|
+
or set `FUSION_ACTOR`).
|
|
109
|
+
|
|
110
|
+
## The CLI crib
|
|
111
|
+
|
|
112
|
+
| Command | What it does |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `fusion new <path>` | scaffold a conformant bucket |
|
|
115
|
+
| `fusion hub [add\|remove]` | list / register / retire buckets |
|
|
116
|
+
| `fusion log <verb> <object>` | append a signed ledger entry |
|
|
117
|
+
| `fusion index` | regenerate INDEX files (logs `indexed` when changed) |
|
|
118
|
+
| `fusion check [path]` | conformance: errors, warnings, honest exit codes |
|
|
119
|
+
| `fusion status [--since …]` | one bucket at a glance |
|
|
120
|
+
| `fusion today` | the composed morning across all hub buckets |
|
|
121
|
+
| `fusion agenda` | dated + active items across buckets |
|
|
122
|
+
| `fusion setup` | install/refresh the skills into detected agents |
|
|
123
|
+
|
|
124
|
+
All take `--json`. `--since last-reflection` scopes to the current
|
|
125
|
+
reflection window. **Exit gate for every skill scenario: `fusion check`
|
|
126
|
+
green before you call the work done.**
|
|
127
|
+
|
|
128
|
+
## When you're blocked
|
|
129
|
+
|
|
130
|
+
- `fusion` not on PATH: stop and tell the human — the install is
|
|
131
|
+
`uv tool install ./fusion/cli` from a clone of the Fusion repository.
|
|
132
|
+
Never imitate the notary by hand: no register writes while the CLI is
|
|
133
|
+
missing.
|
|
134
|
+
- `fusion check` red and you cannot fix it: stop, show the findings
|
|
135
|
+
verbatim, leave the bucket as it stands, and sign nothing that claims
|
|
136
|
+
the work is done. A bucket is a git repo — nothing is unrecoverable.
|
|
137
|
+
- The human rejects a proposal: that is a result, not a failure. Record
|
|
138
|
+
it if the gear's protocol says to (`noted`), and move on.
|
|
139
|
+
|
|
140
|
+
## The four accountabilities
|
|
141
|
+
|
|
142
|
+
| Skill | Owns |
|
|
143
|
+
|---|---|
|
|
144
|
+
| fusion-intake | The gate. Everything that enters, enters through it. |
|
|
145
|
+
| fusion-librarian | The order. Placement, curation, restructuring, reflection. |
|
|
146
|
+
| fusion-planner | The horizon. Activities, agendas, what today looks like. |
|
|
147
|
+
| fusion-analyst | The output. Deliverables that cite their sources. |
|
|
148
|
+
|
|
149
|
+
One skill, one accountability — the ledger says which hat was worn.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# horizon — keep today honest
|
|
2
|
+
|
|
3
|
+
Read-mostly; proposes, changes nothing without a yes.
|
|
4
|
+
|
|
5
|
+
1. `fusion agenda` — dated items and undated actives across buckets;
|
|
6
|
+
`fusion today` — the attention view; `fusion status <root> --since
|
|
7
|
+
last-reflection` for the recent pulse of this bucket.
|
|
8
|
+
2. Read `activities/INDEX.md` + each active plan's summary. For each
|
|
9
|
+
active activity judge: moving (recent ledger touches), **stalled**
|
|
10
|
+
(active but untouched — W5's territory), **expiring** (due within a
|
|
11
|
+
week), **done-in-fact** (finished but never closed).
|
|
12
|
+
3. Report the horizon in four short lists with paths and dates. No
|
|
13
|
+
invented urgency: expiring means a real `due:`, stalled means real
|
|
14
|
+
silence.
|
|
15
|
+
4. Propose the honest corrections: dormant flips, closes, due-date fixes.
|
|
16
|
+
On yes: edit the frontmatter, `fusion log noted "activities/<slug> —
|
|
17
|
+
status: active → dormant" --bucket <root> --as <you>` per flip
|
|
18
|
+
(several flips confirmed in one pass may share one `noted` line
|
|
19
|
+
naming them all) · `fusion index <root>` · `fusion check <root>`.
|
|
20
|
+
Closes go through the close gear.
|