Compare commits

...
10 Commits
Author SHA1 Message Date
Hermes 9f3a99a42e vault-cli: extract to standalone repo, add flake.nix, move one-off scripts to scripts/one-off/ 2026-09-16 13:12:01 -04:00
Hermes 8e8ad466a8 vault-cli: commit one-off migration scripts before extraction to standalone repo 2026-09-16 13:06:57 -04:00
Hermes 57776d46fa vault-cli: update paths for 0 System area restructure (flat 00/01/05/09 layout) 2026-09-16 12:09:58 -04:00
Hermes 069552d1b4 vault move: support area/category/ID codes on either side, incl. cross-level promotion
- vault move now accepts any JD code kind (area/cat/ID) on both old and
  new, not just ID->ID. Enables e.g. 'vault move F.50.09 F.59' to promote
  an ID to a standalone category.
- Refuses if the source has nested JD-coded children (would orphan their
  codes -- that's a bulk renumber, a different and riskier operation).
- Also commits vault_sync_daemon.py (inotify bidirectional /vault<->/data
  sync service, with ntfy notifications on rename/archive/stub/error).
2026-09-15 21:56:27 -04:00
Hermes Agent abf9569a9a vault-librarian: drop .00 category-note check per Ryan's 2026-07-22 ruling 2026-07-22 14:56:52 -04:00
Hermes Agent a347c55479 vault-librarian-prompt: add Step 4 git push
Local commits (auto-fix per-op + Inbox flag commit) are invisible to Ryan and
Obsidian sync until pushed. Add explicit final-step 'git push origin master',
with instruction to report push failure rather than claim changes landed.
2026-07-22 14:01:19 -04:00
Hermes Agent 87bb628ad3 vault-librarian-prompt: rewrite for autonomous tool-enabled cron
Was written for the hermes -z report-only path (no tools). Now that the nightly
is a full agent session with tool access:
- fix stale paths (vault CLI is on $PATH via Nix, not /var/lib/hermes; use
  'vault report --pretty' not the raw python script)
- REMOVE the 'git add -A && commit' step — every vault --execute auto-commits
  per-op, and git add -A would sweep up Ryan's uncommitted journal/Obsidian edits
- flag delivery is now TWO places: append a dated section to the 00.01 Inbox note
  (committed as a single targeted file, safe on a dirty tree), and the agent's
  final response auto-delivers to the Matrix room
2026-07-22 13:57:54 -04:00
Hermes Agent d81d156b79 vault-cli: flip analyze default to Opus, --haiku/--sonnet step down
Opus draws the auto-fix/flag line correctly (Haiku over-fixed on judgment
calls, verified apples-to-apples against the same vault baseline). At ~$0.14/run
it's cheap enough to run nightly. --sonnet/--haiku now step DOWN; --opus kept as
an explicit identity flag; raw -m/--provider still override entirely.

Hermes host defaults (config.yaml default preset, delegation/compression models)
are unchanged — this only affects which model the vault CLI's own hermes -z spawn uses.
2026-07-22 13:44:37 -04:00
Hermes Agent b665413630 vault-cli: tiered analyze — Haiku default, --sonnet/--opus step up
vault analyze now defaults to Haiku/Bedrock (cheap+fast, the nightly tier)
instead of the profile default. --sonnet and --opus step intelligence/cost up
for harder judgment calls; raw -m/--provider still override entirely. Provider
is forced to bedrock only for the haiku (us.anthropic.*) tier; the
global.anthropic.* sonnet/opus IDs use the profile default provider. Adds an
alive banner to stderr (headless -z buffers, so a pass is silent 1-3 min).
2026-07-22 12:34:32 -04:00
Hermes Agent e535ee3608 vault-cli: -m/--provider flags on analyze, git-commit on sync-data, gitignore pyc
- vault analyze now accepts -m MODEL and --provider PROVIDER, passed through to
  hermes -z, so intelligence/spend can be dialed inline per cadence (nightly cheap
  on Haiku/bedrock, ad-hoc on the default smart model).
- sync-data --execute now git-commits like the other write ops (was the only
  mutating op that skipped the commit).
- gitignore + untrack __pycache__/*.pyc build artifacts.
2026-07-22 12:11:43 -04:00
14 changed files with 1244 additions and 34 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
{
description = "vault CLI + Librarian tooling for Ryan's R/J/F/I Obsidian vault";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
in
{
packages = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
vault-cli = pkgs.callPackage ./package.nix { };
in
{
default = vault-cli;
vault-cli = vault-cli;
});
# Convenience module for consumers (e.g. nixos-config) that just want
# `vault` on $PATH for hosts playing a vault role. Mirrors the shape
# nixos-config's modules/hosts/core/vault.nix expects: import this and
# add `environment.systemPackages = [ pkgs.vault-cli ]` (or reference
# `self.packages.${system}.default` directly via a flake input).
nixosModules.default = { pkgs, ... }: {
environment.systemPackages = [ self.packages.${pkgs.system}.default ];
};
};
}
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Add a short-name alias (ID prefix stripped) to every ID-prefixed note's
frontmatter, so Obsidian's alias-autocomplete can produce clean [[ID|Name]]
links without the user typing the ID. Idempotent: skips notes that already
have that alias or non-ID-prefixed notes.
"""
import os, re, sys
VAULT = "/vault"
# Matches "F.50.12 Jen", "R.31.10 Hindi", "F.22.11 Places We've Slept" etc.
ID_TITLE_RE = re.compile(r'^([RJFI](?:\.\d+){1,3})\s+(.+)$')
FRONTMATTER_RE = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL)
def split_frontmatter(text):
m = FRONTMATTER_RE.match(text)
if m:
return m.group(1), text[m.end():]
return None, text
def add_alias(fm_text, alias):
lines = fm_text.split("\n") if fm_text else []
# find aliases: line (list style) or aliases: [a, b]
alias_line_idx = None
for i, l in enumerate(lines):
if re.match(r'^aliases\s*:', l):
alias_line_idx = i
break
if alias_line_idx is None:
lines.append(f"aliases:\n - {alias}")
return "\n".join(lines)
line = lines[alias_line_idx]
# inline list form: aliases: [a, b]
m = re.match(r'^aliases\s*:\s*\[(.*)\]\s*$', line)
if m:
existing = [x.strip().strip('"\'') for x in m.group(1).split(",") if x.strip()]
if alias in existing:
return None # already present
existing.append(alias)
lines[alias_line_idx] = "aliases: [" + ", ".join(existing) + "]"
return "\n".join(lines)
# block list form: aliases:\n - a\n - b
if line.strip() == "aliases:" or re.match(r'^aliases\s*:\s*$', line):
j = alias_line_idx + 1
existing = []
while j < len(lines) and re.match(r'^\s*-\s*', lines[j]):
existing.append(lines[j].strip()[2:].strip().strip('"\''))
j += 1
if alias in existing:
return None
lines.insert(j, f" - {alias}")
return "\n".join(lines)
# single scalar alias: aliases: Foo
scalar = line.split(":", 1)[1].strip().strip('"\'')
if scalar == alias:
return None
lines[alias_line_idx] = "aliases:\n - " + scalar + f"\n - {alias}"
return "\n".join(lines)
def main():
execute = "--execute" in sys.argv
changed = 0
skipped_no_id = 0
skipped_has_alias = 0
errors = []
for dp, _, fns in os.walk(VAULT):
if ".git" in dp.split(os.sep):
continue
for fn in fns:
if not fn.lower().endswith(".md"):
continue
base = fn[:-3]
m = ID_TITLE_RE.match(base)
if not m:
skipped_no_id += 1
continue
alias = m.group(2)
p = os.path.join(dp, fn)
try:
with open(p, "r", encoding="utf-8", errors="ignore") as f:
text = f.read()
except Exception as e:
errors.append((p, str(e)))
continue
fm, rest = split_frontmatter(text)
new_fm = add_alias(fm or "", alias)
if new_fm is None:
skipped_has_alias += 1
continue
new_text = f"---\n{new_fm}\n---\n\n{rest.lstrip(chr(10))}" if fm is None else f"---\n{new_fm}\n---\n{rest}"
changed += 1
if execute:
with open(p, "w", encoding="utf-8") as f:
f.write(new_text)
print(f"Notes with ID-prefixed title: {changed + skipped_has_alias}")
print(f" Would add alias: {changed}")
print(f" Already had alias: {skipped_has_alias}")
print(f"Notes without ID prefix (skipped, not applicable): {skipped_no_id}")
if errors:
print(f"Errors: {len(errors)}")
for p, e in errors[:10]:
print(f" {p}: {e}")
if not execute:
print("\n(dry run -- pass --execute to write changes)")
if __name__ == "__main__":
main()
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Scan /vault for [[wikilinks]] and report which targets don't resolve
to an actual note. Obsidian resolves links by basename match anywhere
in the vault (unless the link includes a path), so we build an index
of all note basenames (and their full relative paths) first.
"""
import os, re, json
from collections import defaultdict
VAULT = "/vault"
LINK_RE = re.compile(r'\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]')
def all_notes():
idx = defaultdict(list) # basename (no ext, lowercase) -> [full paths]
all_paths = []
for dp, dirnames, fns in os.walk(VAULT):
if ".git" in dp.split(os.sep):
continue
for fn in fns:
if fn.lower().endswith(".md"):
p = os.path.join(dp, fn)
base = fn[:-3]
idx[base.lower()].append(p)
all_paths.append(p)
return idx, all_paths
def resolve(link_target, index, current_dir):
# Obsidian: link may be a bare name, or a relative/vault path (with /)
target = link_target.strip()
if "/" in target:
# path-style link; try resolving relative to vault root or current dir
candidates = [
os.path.join(VAULT, target + ".md"),
os.path.join(VAULT, target),
os.path.join(current_dir, target + ".md"),
os.path.join(current_dir, target),
]
for c in candidates:
if os.path.isfile(c):
return True
# also try basename match as fallback
base = os.path.basename(target)
return base.lower() in index
else:
return target.lower() in index
def main():
index, all_paths = all_notes()
unresolved = defaultdict(list) # target -> [source files]
total_links = 0
for p in all_paths:
try:
with open(p, "r", encoding="utf-8", errors="ignore") as f:
text = f.read()
except Exception:
continue
for m in LINK_RE.finditer(text):
target = m.group(1)
total_links += 1
if not resolve(target, index, os.path.dirname(p)):
unresolved[target].append(os.path.relpath(p, VAULT))
print(f"Total wikilinks scanned: {total_links}")
print(f"Unique unresolved targets: {len(unresolved)}")
print(f"Total unresolved link occurrences: {sum(len(v) for v in unresolved.values())}")
# dump full detail to json for further processing
out = {t: srcs for t, srcs in unresolved.items()}
with open("/tmp/unresolved_links.json", "w") as f:
json.dump(out, f, indent=2)
print("Full detail written to /tmp/unresolved_links.json")
# sorted by occurrence count desc
by_count = sorted(unresolved.items(), key=lambda kv: -len(kv[1]))
print("\n=== TOP 40 unresolved targets by occurrence count ===")
for target, srcs in by_count[:40]:
print(f" {len(srcs):4d}x [[{target}]]")
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Apply the two link-fix passes across /vault:
1. fix_map: [[OldTarget]] -> [[NewNote|OldTarget]] (preserves display text)
Also handles piped/heading variants: [[OldTarget|Display]] -> [[NewNote|Display]]
[[OldTarget#Heading]] -> [[NewNote#Heading]]
2. unlink_targets: [[Target]] -> Target (plain text, drop brackets)
Also handles piped: [[Target|Display]] -> Display
"""
import os, re, json, sys
VAULT = "/vault"
with open("/tmp/link_fix_map.json") as f:
FIX_MAP = json.load(f)
with open("/tmp/unlink_targets.json") as f:
UNLINK = set(json.load(f))
# Full link regex capturing target, optional #heading, optional |display
LINK_RE = re.compile(r'\[\[([^\]|#]+)(#[^\]|]*)?(\|[^\]]*)?\]\]')
def process_text(text):
changed = False
def repl(m):
nonlocal changed
target = m.group(1)
heading = m.group(2) or ""
pipe_display = m.group(3) # includes leading |
target_stripped = target.strip()
if target_stripped in FIX_MAP:
new_target = FIX_MAP[target_stripped]
changed = True
if pipe_display:
# keep existing custom display text
return f"[[{new_target}{heading}{pipe_display}]]"
else:
# preserve original text as display so prose doesn't change visually
return f"[[{new_target}{heading}|{target_stripped}]]"
if target_stripped in UNLINK:
changed = True
if pipe_display:
display = pipe_display[1:] # strip leading |
return display
else:
return target_stripped
return m.group(0) # unchanged
new_text = LINK_RE.sub(repl, text)
return new_text, changed
def main():
execute = "--execute" in sys.argv
files_changed = 0
total_fix_applied = 0
total_unlink_applied = 0
for dp, _, fns in os.walk(VAULT):
if ".git" in dp.split(os.sep):
continue
for fn in fns:
if not fn.lower().endswith(".md"):
continue
p = os.path.join(dp, fn)
try:
with open(p, "r", encoding="utf-8", errors="ignore") as f:
text = f.read()
except Exception:
continue
new_text, changed = process_text(text)
if changed:
files_changed += 1
if execute:
with open(p, "w", encoding="utf-8") as f:
f.write(new_text)
print(f"Files changed: {files_changed}")
if not execute:
print("(dry run -- pass --execute to write)")
if __name__ == "__main__":
main()
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Sort /data/_unsorted (old pre-RJFI JD tree) into /data/R,J,F,I storage tree.
Dry-run by default; pass --execute to actually move files.
"""
import os, shutil, sys, csv
UNSORTED = "/data/_unsorted"
DATA = "/data"
# old_prefix -> new_prefix (folder-level rename/move rules)
# Order matters: longer/more specific prefixes first.
MAPPING = [
# --- WhatsApp exports: wholesale move, old->new folder rename ---
("1 Life admin/11 Me & other living things/11.54 Whatsapp exports",
"R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.54 Whatsapp exports"),
# --- 12 Home, house, and transport -> split F.11 (home) / F.12 (vehicles) ---
("1 Life admin/12 Home, house, and transport/12.12 Home insurance & claims",
"F Family/F.1 Home, household & finances/F.11 Home & property/F.11.12 Home insurance & claims"),
("1 Life admin/12 Home, house, and transport/12.21 Electricity, gas, & water",
"F Family/F.1 Home, household & finances/F.11 Home & property/F.11.21 Electricity, gas, & water"),
("1 Life admin/12 Home, house, and transport/12.23 All other utilities & services",
"F Family/F.1 Home, household & finances/F.11 Home & property/F.11.23 All other utilities & services"),
("1 Life admin/12 Home, house, and transport/12.24 Renovations & improvements",
"F Family/F.1 Home, household & finances/F.11 Home & property/F.11.24 Renovations & improvements"),
("1 Life admin/12 Home, house, and transport/12.25 House repairs, maintenance, & upkeep",
"F Family/F.1 Home, household & finances/F.11 Home & property/F.11.24 Renovations & improvements"),
("1 Life admin/12 Home, house, and transport/12.61 Move out of 6 Dorothy",
"F Family/F.1 Home, household & finances/F.11 Home & property/F.11.61 Move out of 6 Dorothy"),
("1 Life admin/12 Home, house, and transport/12.30 Vehicles",
"F Family/F.1 Home, household & finances/F.12 Vehicles/F.12.30 Vehicles"),
("1 Life admin/12 Home, house, and transport/12.32 Motor vehicle auto insurance & claims",
"F Family/F.1 Home, household & finances/F.12 Vehicles/F.12.32 Motor vehicle auto insurance & claims"),
("1 Life admin/12 Home, house, and transport/12.33 Car repairs, maintenance, & upkeep",
"F Family/F.1 Home, household & finances/F.12 Vehicles/F.12.33 Car repairs, maintenance, & upkeep"),
# --- 13 Money -> split R.13 (solo) / F.13 (joint); tax + financial insurance are joint per design doc ---
("1 Life admin/13 Money earned, spent, saved, & owed/13.54 Tax returns & accounting",
"F Family/F.1 Home, household & finances/F.13 Household finances/F.13.05 Tax returns & accounting"),
("1 Life admin/13 Money earned, spent, saved, & owed/13.12 Financial insurance & claims",
"F Family/F.1 Home, household & finances/F.13 Household finances/F.13.12 Financial insurance & claims"),
("1 Life admin/13 Money earned, spent, saved, & owed/13.44 Cryptocurrency",
"R Ryan/R.1 Life admin/R.13 Personal finance/R.13.44 Cryptocurrency"),
# --- 11 Me & other living things -> mostly R (Ryan personal) ---
("1 Life admin/11 Me & other living things/11.11 Birth certificate & proof of name",
"R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.11 Birth certificate & proof of name"),
("1 Life admin/11 Me & other living things/11.14 Licenses",
"R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.14 Licenses"),
("1 Life admin/11 Me & other living things/11.16 Legal documents & certificates",
"R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.16 Legal documents & certificates"),
("1 Life admin/11 Me & other living things/11.28 Reproductive health",
"R Ryan/R.1 Life admin/R.12 Health & body/R.12.28 Reproductive health"),
("1 Life admin/11 Me & other living things/11.83 My library",
"R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.83 Ryan's library"),
# --- 15 Travel -> R.15 Travel (solo trips; matches exactly) ---
("1 Life admin/15 Travel, events, & entertainment/15.41 All short trips",
"R Ryan/R.1 Life admin/R.15 Travel/R.15.41 All short trips"),
# --- 2 Career -> R.2 Career, ~1:1 ---
("2 Career/21 Perfect Day", "R Ryan/R.2 Career/R.21 Perfect Day"),
("2 Career/22 Sejour", "R Ryan/R.2 Career/R.22 Sejour"),
("2 Career/23 Consulting", "R Ryan/R.2 Career/R.23 Consulting"),
("2 Career/24 Stanford", "R Ryan/R.2 Career/R.24 Stanford"),
("2 Career/29 Old applications & archives", "R Ryan/R.2 Career/R.29 Old applications & archives"),
# --- 3 Hobbies -> R.3 Hobbies, ~1:1 ---
("3 Hobbies & interests/31 Language learning", "R Ryan/R.3 Hobbies & interests/R.31 Language learning"),
("3 Hobbies & interests/36 Projects", "R Ryan/R.3 Hobbies & interests/R.36 Projects"),
# --- 4 Flying -> R.4 Flying, ~1:1 ---
("4 Flying/41 Certification", "R Ryan/R.4 Flying/R.41 Certification, legal, & insurance"),
("4 Flying/43 Resources", "R Ryan/R.4 Flying/R.43 Resources"),
]
# Items needing human judgment - do NOT auto-move, just report.
FLAG_FOR_REVIEW = [
"1 Life admin/10 Management of area 10-19", # SMS backups - unclear whose/where
"1 Life admin/11 Me & other living things/11.43 My family", # Mom's book proposal
"1 Life admin/11 Me & other living things/11.09 Archive for category 11", # AI deck for Jen
"1 Life admin/11 Me & other living things/11.71 My career sales pitch",
"1 Life admin/13 Money earned, spent, saved, & owed/13.41 Bank accounts", # solo or joint?
"1 Life admin/13 Money earned, spent, saved, & owed/13.01 Inbox for category 13",
"1 Life admin/12 Home, house, and transport/12.01 Inbox for category 12",
"1 Life admin/14 My digital life/14.14 My data storage & backups",
"3 Hobbies & interests/30 Management for Hobbies",
]
def find_files(root):
for dirpath, _, filenames in os.walk(root):
for fn in filenames:
yield os.path.join(dirpath, fn)
def plan_moves():
moves = [] # (src, dst)
unmatched = []
for src in find_files(UNSORTED):
rel = os.path.relpath(src, UNSORTED)
matched = False
for old_prefix, new_prefix in MAPPING:
if rel == old_prefix or rel.startswith(old_prefix + os.sep):
suffix = rel[len(old_prefix):]
dst = os.path.join(DATA, new_prefix.replace("/", os.sep) + suffix)
moves.append((src, dst))
matched = True
break
if not matched:
is_flagged = any(rel.startswith(f) for f in FLAG_FOR_REVIEW)
unmatched.append((rel, is_flagged))
return moves, unmatched
def main():
execute = "--execute" in sys.argv
moves, unmatched = plan_moves()
print(f"=== PLANNED MOVES: {len(moves)} files ===")
by_target = {}
for src, dst in moves:
key = os.path.dirname(dst)
by_target[key] = by_target.get(key, 0) + 1
for k, v in sorted(by_target.items()):
print(f" {v:5d} -> {k}")
print(f"\n=== FLAGGED FOR REVIEW (not moved): {sum(1 for _, f in unmatched if f)} files ===")
flagged_dirs = {}
for rel, is_flagged in unmatched:
if is_flagged:
d = os.path.dirname(rel)
flagged_dirs[d] = flagged_dirs.get(d, 0) + 1
for k, v in sorted(flagged_dirs.items()):
print(f" {v:5d} :: {k}")
print(f"\n=== TRULY UNMATCHED (no mapping rule, not flagged): {sum(1 for _, f in unmatched if not f)} files ===")
other_dirs = {}
for rel, is_flagged in unmatched:
if not is_flagged:
d = os.path.dirname(rel)
other_dirs[d] = other_dirs.get(d, 0) + 1
for k, v in sorted(other_dirs.items()):
print(f" {v:5d} :: {k}")
if execute:
print("\n=== EXECUTING MOVES ===")
moved = 0
for src, dst in moves:
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.exists(dst):
print(f" SKIP (dst exists): {dst}")
continue
shutil.move(src, dst)
moved += 1
print(f"Moved {moved} files.")
else:
print("\n(dry run only -- pass --execute to actually move files)")
if __name__ == "__main__":
main()
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Final placement pass for the 8 flagged/reviewed _unsorted items."""
import os, shutil, sys
U = "/data/_unsorted"
D = "/data"
MOVES = [
# (src relative to _unsorted, dst absolute under /data)
("1 Life admin/10 Management of area 10-19/10.09 Archive for area 10-19/SMS Backups as of 2026-04-07/data",
"R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.55 SMS backups"),
("1 Life admin/11 Me & other living things/11.09 Archive for category 11/2025-08-07 AI Deck for Jen.pdf",
"J Jen/J.0 Meta/J.00 Meta/J.00.01 Inbox for Jen/2025-08-07 AI Deck for Jen.pdf"),
("1 Life admin/11 Me & other living things/11.43 My family/Moms Anandi Book & Screenplay/America's Niece - Proposal.pdf",
"F Family/F.5 People/F.50 Family/F.50.20 Pandyas/F.50.21 Nandini Pandya/America's Niece - Proposal.pdf"),
("1 Life admin/11 Me & other living things/11.71 My career sales pitch/Ryan_Pandya_Resume___2024_10-1.pdf",
"R Ryan/R.2 Career/R.20 Curiosity/R.20.02 Resume/Ryan_Pandya_Resume___2024_10-1.pdf"),
("1 Life admin/11 Me & other living things/11.71 My career sales pitch/Ryan_Pandya_Resume.pdf",
"R Ryan/R.2 Career/R.20 Curiosity/R.20.02 Resume/Ryan_Pandya_Resume.pdf"),
("1 Life admin/12 Home, house, and transport/12.01 Inbox for category 12/2021-03-08 - 6 Dorothy sheet from Jason.pdf",
"F Family/F.1 Home, household & finances/F.11 Home & property/F.11.09 Old Homes/2021-2026 Dorothy/2021-03-08 - 6 Dorothy sheet from Jason.pdf"),
("1 Life admin/13 Money earned, spent, saved, & owed/13.01 Inbox for category 13/Voided check.jpg",
"R Ryan/R.1 Life admin/R.13 Personal finance/R.13.01 Inbox/Voided check.jpg"),
("1 Life admin/13 Money earned, spent, saved, & owed/13.41 Bank accounts/Ryan Wells Fargo direct deposit instructions.pdf",
"R Ryan/R.1 Life admin/R.13 Personal finance/R.13.01 Inbox/Ryan Wells Fargo direct deposit instructions.pdf"),
("1 Life admin/14 My digital life/14.14 My data storage & backups/Google backup key.pdf",
"R Ryan/R.1 Life admin/R.14 My digital life/R.14.01 Inbox/Google backup key.pdf"),
("3 Hobbies & interests/30 Management for Hobbies/30.01 Inbox for Hobbies/elixirbook.pdf",
"R Ryan/R.3 Hobbies & interests/R.35 Computers/R.35.21 Computer & programming language textbooks/elixirbook.pdf"),
]
execute = "--execute" in sys.argv
for rel_src, rel_dst in MOVES:
src = os.path.join(U, rel_src)
dst = os.path.join(D, rel_dst)
exists = os.path.exists(src)
print(f"{'OK ' if exists else 'MISS'} {src} -> {dst}")
if execute and exists:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.move(src, dst)
if execute:
print("Done.")
else:
print("\n(dry run -- pass --execute)")
+60 -3
View File
@@ -10,12 +10,18 @@ Subcommands:
vault search <query> find IDs/notes by code, title, or content vault search <query> find IDs/notes by code, title, or content
vault show <ID> show an ID's note + its /data home + blob count vault show <ID> show an ID's note + its /data home + blob count
vault create <ID> "<title>" create ID note (folder+folder-note) in /vault AND /data vault create <ID> "<title>" create ID note (folder+folder-note) in /vault AND /data
vault move <oldID> <newID> renumber: rename note+folder, rewrite backlinks, move /data dir vault move <oldCode> <newCode> renumber/promote: area, category, or ID code, either side (e.g.
cross-level "vault move F.50.09 F.59" promotes an ID to a
standalone category); rewrites backlinks, moves /data dir
vault rename <ID> "<title>" retitle in place, rewrite backlinks vault rename <ID> "<title>" retitle in place, rewrite backlinks
vault sync-data reconcile /data skeleton to match manifest (create missing dirs) vault sync-data reconcile /data skeleton to match manifest (create missing dirs)
vault manifest regenerate the topology manifest note vault manifest regenerate the topology manifest note
vault report [--pretty] deterministic Librarian report (delegates to vault-librarian.py) vault report [--pretty] deterministic Librarian report (delegates to vault-librarian.py)
vault analyze spawn a Hermes Librarian pass (ad-hoc; uses the cron prompt) vault analyze [--haiku|--sonnet|--opus] [-m MODEL] [--provider PROVIDER]
spawn a Hermes Librarian pass. DEFAULT: Opus (smart —
draws the auto-fix/flag line correctly, ~$0.14/run so cheap
enough for nightly). --sonnet / --haiku step DOWN for
cheaper/faster passes; -m/--provider override entirely.
""" """
import os, re, sys, subprocess import os, re, sys, subprocess
@@ -28,6 +34,19 @@ HERMES = "/run/current-system/sw/bin/hermes"
LIBRARIAN = os.path.join(_SELF_DIR, "vault-librarian.py") LIBRARIAN = os.path.join(_SELF_DIR, "vault-librarian.py")
PROMPT = os.path.join(_SELF_DIR, "vault-librarian-prompt.md") PROMPT = os.path.join(_SELF_DIR, "vault-librarian-prompt.md")
# `vault analyze` model tiers. Default is Opus — smart enough to draw the auto-fix/
# flag line correctly, and ~$0.14/run so cheap enough for a nightly pass. --sonnet /
# --haiku step DOWN. IDs verified against this host's Hermes config + Bedrock provisioning.
ANALYZE_TIERS = {
"haiku": "us.anthropic.claude-haiku-4-5-20251001-v1:0",
"sonnet": "global.anthropic.claude-sonnet-5",
"opus": "global.anthropic.claude-opus-4-8",
}
# Only the haiku (us.anthropic.*) tier is known to route via an explicit `bedrock`
# provider. The global.anthropic.* IDs use the profile's default provider unless the
# caller passes --provider, to avoid a provider/model-registration mismatch.
ANALYZE_TIER_PROVIDER = {"haiku": "bedrock"}
WIKILINK_RE = re.compile(r'(!?\[\[)([^\]#|]+)((?:#[^\]|]+)?(?:\|[^\]]+)?)(\]\])') WIKILINK_RE = re.compile(r'(!?\[\[)([^\]#|]+)((?:#[^\]|]+)?(?:\|[^\]]+)?)(\]\])')
@@ -131,7 +150,45 @@ def main():
sys.exit(subprocess.run([sys.executable, LIBRARIAN] + argv).returncode) sys.exit(subprocess.run([sys.executable, LIBRARIAN] + argv).returncode)
elif cmd == "analyze": elif cmd == "analyze":
prompt = open(PROMPT, encoding="utf-8").read() prompt = open(PROMPT, encoding="utf-8").read()
subprocess.run([HERMES, "-z", prompt]) # Model tier: default Opus (smart — draws the auto-fix/flag line correctly,
# ~$0.14/run so cheap enough to run nightly). --haiku/--sonnet step DOWN.
# Raw -m/--provider override the tier entirely (escape hatch for future models
# or non-Bedrock providers). Tier flags are last-wins if combined.
tier = "opus"
if "--sonnet" in argv:
tier = "sonnet"
if "--haiku" in argv:
tier = "haiku"
if "--opus" in argv:
tier = "opus"
model = ANALYZE_TIERS[tier]
provider = ANALYZE_TIER_PROVIDER.get(tier) # None for sonnet/opus (use profile default)
if "-m" in argv:
i = argv.index("-m")
if i + 1 >= len(argv):
die("-m requires a model name")
model = argv[i + 1]
tier = "custom"
provider = None # custom model: don't force a provider unless asked
if "--provider" in argv:
i = argv.index("--provider")
if i + 1 >= len(argv):
die("--provider requires a provider name")
provider = argv[i + 1]
hcmd = [HERMES, "-z", prompt, "-m", model]
if provider:
hcmd += ["--provider", provider]
# Banner: headless oneshot buffers output, so show it's alive + what it costs.
print(f"→ spawning Hermes Librarian pass [tier={tier} provider={provider or 'profile-default'}]",
file=sys.stderr)
print(f" model: {model}", file=sys.stderr)
print(" output is buffered; a full pass can take 1–3 min before you see anything.",
file=sys.stderr)
if tier == "opus":
print(" step DOWN: vault analyze --sonnet (or --haiku) for cheaper/faster passes",
file=sys.stderr)
sys.stderr.flush()
subprocess.run(hcmd)
else: else:
die(f"unknown command '{cmd}'") die(f"unknown command '{cmd}'")
+32 -10
View File
@@ -6,27 +6,30 @@ Your job: keep the vault coherent, accurate, up to date, and ontologically sound
2. The vault (JDex/map) lives at `/vault`; blob territory at `/data`. Notes are the map, 2. The vault (JDex/map) lives at `/vault`; blob territory at `/data`. Notes are the map,
files are the territory, joined by ID number — NEVER copy notes into /data. files are the territory, joined by ID number — NEVER copy notes into /data.
3. The authoritative topology is the manifest note 3. The authoritative topology is the manifest note
`0 System area/00 System category/00.05 Vault topology (machine-readable)`. `0 System area/05 AI Agent/05 AI Agent.md`.
4. All structural operations go through the `vault` CLI at /var/lib/hermes/vault 4. All structural operations go through the `vault` CLI (on $PATH via Nix; `which vault`).
(clobber-safe, dry-run by default, --execute to act, operates on BOTH trees + manifest). Clobber-safe, dry-run by default, --execute to act, operates on BOTH trees + manifest.
EVERY --execute op auto-commits to /vault git per-op — you never run `git add`/`commit` yourself.
## Step 1 — get the deterministic report ($0, no judgment) ## Step 1 — get the deterministic report ($0, no judgment)
Run: python3 /var/lib/hermes/vault-librarian.py --pretty Run: vault report --pretty
This gives you: broken wikilinks (introduced vs pre-existing), cross-tree topology diff, This gives you: broken wikilinks (introduced vs pre-existing), cross-tree topology diff,
JD structural violations (categories that should be IDs, missing .00/folder-notes, JD structural violations (categories that should be IDs, missing .00/folder-notes,
extend-notation `NN+` leaves that need real IDs, >2-level nesting), to-do harvest, and extend-notation `NN+` leaves that need real IDs, >2-level nesting), to-do harvest, and
category-health signals. Read it fully before deciding anything. category-health signals. Read it fully before deciding anything.
## Step 2 — AUTO-FIX (silent, deterministic, reversible) ## Step 2 — AUTO-FIX (silent, deterministic, reversible)
Only do these when the correct action is UNAMBIGUOUS. Use the `vault` CLI, then git-commit. Only do these when the correct action is UNAMBIGUOUS. Use the `vault` CLI with --execute;
it commits each op for you (undo = git revert). Do NOT run `git add -A` or `git commit`
yourself — that would sweep up Ryan's uncommitted journal/Obsidian edits.
- Scaffold missing /data skeleton dirs so every vault ID has a blob home (`vault sync-data --execute`). - Scaffold missing /data skeleton dirs so every vault ID has a blob home (`vault sync-data --execute`).
- Fix a broken wikilink when EXACTLY ONE existing note obviously matches the stale target. - Fix a broken wikilink when EXACTLY ONE existing note obviously matches the stale target
- Create a missing `.00` management note or missing folder-note (empty is fine). (use `vault rename`/`vault move` where it applies; otherwise edit the one link via Obsidian MCP).
- Create a missing `.00` management note or missing folder-note (`vault create --execute`).
- Check off a to-do checkbox that is verifiably complete (linked artifact exists / the note - Check off a to-do checkbox that is verifiably complete (linked artifact exists / the note
itself states it's done). When in doubt, do NOT check it — flag instead. itself states it's done). When in doubt, do NOT check it — flag instead.
Commit auto-fixes: cd /vault && git add -A && git commit -m "Librarian: <summary>".
## Step 3 — FLAG (post to this Matrix room, do NOT act) ## Step 3 — FLAG (write to the Inbox, then message this Matrix room)
Anything ambiguous or structural where the right answer needs Ryan's judgment: Anything ambiguous or structural where the right answer needs Ryan's judgment:
- An ID mis-placed under the wrong category (e.g. journalling under an Identity category), - An ID mis-placed under the wrong category (e.g. journalling under an Identity category),
or overlaps/clashes between IDs. or overlaps/clashes between IDs.
@@ -38,7 +41,26 @@ Anything ambiguous or structural where the right answer needs Ryan's judgment:
- Any single-home violation, sprawl nearing the 10-category / 10-ID limits, or note that - Any single-home violation, sprawl nearing the 10-category / 10-ID limits, or note that
feels ontologically mis-filed. feels ontologically mis-filed.
NOTE: the `0 System area/09 Archive of pre-RJFI tree` subtree is a SANCTIONED garbage dump HOW to flag (two places, both required):
1. APPEND a dated section to the Inbox note (via Obsidian MCP, append mode)
`0 System area/01 System Inbox/01 System Inbox.md`
Header: `## Librarian flags — YYYY-MM-DD`. Under it, one `### N. <short title>` per flag with
the issue, your recommended resolution, and the exact `vault` command you'd run if approved.
Then commit ONLY that one file (safe even if Ryan's tree is otherwise dirty):
cd /vault && git add "0 System area/01 System Inbox/01 System Inbox.md" \
&& git commit -m "Librarian: flags for YYYY-MM-DD"
(This single-file add is the ONLY git command you run by hand — everything else auto-commits.
Never `git add -A`.)
2. Your FINAL RESPONSE is auto-delivered to this Matrix room — make it the mobile-friendly
summary (see output contract). The Inbox note holds the detail; the message is the nudge.
## Step 4 — PUSH (so Obsidian sync + Ryan actually see the changes)
Local commits are invisible to Ryan until pushed. As the LAST action, push the vault:
cd /vault && git push origin master
(Auto-fix ops committed per-op and the Inbox flag commit all get pushed together. If the
push fails, say so in your final message — do NOT claim changes landed.)
NOTE: the `0 System area/09 System Archive` subtree is a SANCTIONED garbage dump
(Ryan's ruling). NEVER flag, fix, or scan inside it — its broken links, stray files, and (Ryan's ruling). NEVER flag, fix, or scan inside it — its broken links, stray files, and
odd structure are all intentional/allowed. The tooling already excludes it. odd structure are all intentional/allowed. The tooling already excludes it.
+4 -9
View File
@@ -61,15 +61,10 @@ def check_structure():
for fn in fns: for fn in fns:
if vl.EXTEND_RE.match(fn): if vl.EXTEND_RE.match(fn):
out["extend_notation"].append(os.path.relpath(os.path.join(dp, fn), vl.VAULT)) out["extend_notation"].append(os.path.relpath(os.path.join(dp, fn), vl.VAULT))
# (b) missing .00 per category + (c) missing folder-note per structural folder # (b) .00 category-management notes are NOT a vault-wide requirement (ruling 2026-07-22:
for code, rel in s["cats"].items(): # category-level "about this category" info can live in the category's own SYS.AC note;
catf = os.path.join(vl.VAULT, rel) # .00 is only added ad-hoc when a category needs its own inbox/archive/store). Not checked.
has_dot00 = any(vl.id_code(d) == code + ".00" # (c) missing folder-note per structural folder
for d in os.listdir(catf) if os.path.isdir(os.path.join(catf, d)))
# a .00 may also be a bare note; check for any NN.00 leaf
if not has_dot00:
out["missing_dot00"].append(code)
# folder-notes: every area/cat/id folder should contain <name>.md
for level in ("areas", "cats", "ids"): for level in ("areas", "cats", "ids"):
for code, rel in s[level].items(): for code, rel in s[level].items():
f = os.path.join(vl.VAULT, rel) f = os.path.join(vl.VAULT, rel)
+2 -2
View File
@@ -99,7 +99,7 @@ def walk_structure(root, include_meta=False):
return {"systems": systems, "areas": areas, "cats": cats, "ids": ids} return {"systems": systems, "areas": areas, "cats": cats, "ids": ids}
ARCHIVE_SUBPATH = os.path.join(SYSTEM_META, "09 Archive of pre-RJFI tree") ARCHIVE_SUBPATH = os.path.join(SYSTEM_META, "09 System Archive")
def is_archived(relpath): def is_archived(relpath):
@@ -237,7 +237,7 @@ def topology_diff(vault_root=VAULT, data_root=DATA):
# ---- topology manifest (the 00-area source-of-truth note) ---- # ---- topology manifest (the 00-area source-of-truth note) ----
MANIFEST_REL = "0 System area/00 System category/00.05 Vault topology (machine-readable)/00.05 Vault topology (machine-readable).md" MANIFEST_REL = "0 System area/05 AI Agent/05 AI Agent.md"
MANIFEST_BEGIN = "<!-- VAULT-TOPOLOGY-JSON:BEGIN -->" MANIFEST_BEGIN = "<!-- VAULT-TOPOLOGY-JSON:BEGIN -->"
MANIFEST_END = "<!-- VAULT-TOPOLOGY-JSON:END -->" MANIFEST_END = "<!-- VAULT-TOPOLOGY-JSON:END -->"
+121 -10
View File
@@ -39,6 +39,57 @@ def _find_cat_folder(root, catcode):
return None return None
# --- code-kind helpers for `move`, which supports ID, category, AND area
# codes as either side — including cross-level promotion/demotion (e.g.
# "F.50.09" -> "F.59": an ID promoted to a standalone category). ---
ID_CODE_RE = re.compile(r'^[RJFI]\.\d\d\.\d\d$')
CAT_CODE_RE = re.compile(r'^[RJFI]\.\d\d$')
AREA_CODE_RE = re.compile(r'^[RJFI]\.\d$')
def _code_kind(code):
"""'id' / 'cat' / 'area' / None (not a recognized code shape)."""
if ID_CODE_RE.match(code):
return "id"
if CAT_CODE_RE.match(code):
return "cat"
if AREA_CODE_RE.match(code):
return "area"
return None
def _find_folder_by_code(root, code, kind):
code_fn = {"id": vl.id_code, "cat": vl.cat_code, "area": vl.area_code}[kind]
for dp, dns, fns in os.walk(root):
dns[:] = [d for d in dns if d not in vl.SKIP_DIRS]
if code_fn(os.path.basename(dp)) == code:
return dp
return None
def _nested_jd_children(folder):
"""
List any descendant folder (relative path) whose basename is itself
JD-coded (area/category/ID shaped). Moving/renumbering a category or
area whose children's own folder names embed the OLD number (e.g.
"F.50.11 Ryan" inside category "F.50 Family") would orphan those
children's codes — that's a bulk-renumber operation, not a simple
move, so `move` refuses rather than silently leaving stale numbers.
An ID-kind source is always safe here since IDs never have nested
JD-coded children by convention; the check is generic and simply
finds nothing in that case.
"""
found = []
for dp, dns, fns in os.walk(folder):
dns[:] = [d for d in dns if d not in vl.SKIP_DIRS]
if dp == folder:
continue
base = os.path.basename(dp)
if vl.ID_RE.match(base) or vl.CAT_RE.match(base) or vl.AREA_RE.match(base):
found.append(os.path.relpath(dp, folder))
return found
def _rewrite_links(old_stem, new_stem, execute): def _rewrite_links(old_stem, new_stem, execute):
"""Rewrite [[old_stem]] -> [[new_stem]] vault-wide. Returns count.""" """Rewrite [[old_stem]] -> [[new_stem]] vault-wide. Returns count."""
n = 0 n = 0
@@ -101,24 +152,82 @@ def op_create(argv, execute):
def op_move(argv, execute): def op_move(argv, execute):
"""
Renumber/move an area, category, or ID folder to a new code — including
cross-level promotion/demotion (e.g. an ID promoted to a standalone
category: `vault move F.50.09 F.59`). Same-level renumbers (ID->ID,
cat->cat, area->area) work as before; the source and destination no
longer have to be the same kind.
Refuses (clobber-safe) if:
- the destination code already exists anywhere in that system
- the source has JD-coded descendants (moving a category/area that
still has numbered children would orphan their codes — that's a
bulk-renumber, a different and much riskier operation, not this one)
- moving cross-system (letter must match; there's no valid target
parent on a different system)
"""
if len(argv) < 2: if len(argv) < 2:
_die("usage: vault move <oldID> <newID>") _die("usage: vault move <oldCode> <newCode> (ID, category, or area code; can differ in kind)")
old, new = argv[0], argv[1] old, new = argv[0], argv[1]
old_kind = _code_kind(old)
new_kind = _code_kind(new)
if not old_kind:
_die(f"'{old}' is not a recognized area/category/ID code (expected SYS.N, SYS.NN, or SYS.NN.NN)")
if not new_kind:
_die(f"'{new}' is not a recognized area/category/ID code (expected SYS.N, SYS.NN, or SYS.NN.NN)")
if old[0] != new[0]:
_die(f"cross-system move not supported: {old} is system '{old[0]}', {new} is system '{new[0]}'")
root = _sys_root(old[0]) root = _sys_root(old[0])
oldf = _find_id_folder(root, old) oldf = _find_folder_by_code(root, old, old_kind)
if not oldf: if not oldf:
_die(f"source ID {old} not found") _die(f"source {old_kind} {old} not found")
newroot = _sys_root(new[0]) newroot = _sys_root(new[0])
if _find_id_folder(newroot, new): if _find_folder_by_code(newroot, new, new_kind):
_die(f"CLOBBER: destination ID {new} already exists") _die(f"CLOBBER: destination {new_kind} {new} already exists")
newcat = new[:new.rindex(".")]
newcatf = _find_cat_folder(newroot, newcat) nested = _nested_jd_children(oldf)
if not newcatf: if nested:
_die(f"destination category {newcat} does not exist") kind_word = {"id": "ID", "cat": "category", "area": "area"}[old_kind]
_die(
f"{old} ({kind_word}) has {len(nested)} JD-coded child folder(s) "
f"(e.g. {nested[0]}) — moving it would orphan their codes. "
f"This needs a bulk renumber, not `move`. Refusing."
)
if old_kind != new_kind:
print(f"NOTE: cross-level move — {old} is a {old_kind}, {new} is a {new_kind}.")
# Destination's PARENT folder must exist. For a same-kind move this is
# "the category/area that will contain the ID/category". For a
# cross-level promotion (e.g. id -> cat), the parent is one level up
# from what `new`'s own kind would normally sit under — i.e. new's
# area, found the normal way regardless of what kind old was.
# NB: parent code can't be derived by slicing at the last '.' — a
# category code like "F.58" has only ONE dot, so that would yield "F"
# (a system letter) instead of "F.5" (the area it belongs to). Derive
# it from the numeric digits directly instead.
if new_kind == "area":
newparentf = newroot # area's parent is the system root itself
else:
if new_kind == "id":
parent_kind = "cat"
parent_code = new[: new.rindex(".")] # "F.58.03" -> "F.58"
else: # new_kind == "cat"
parent_kind = "area"
catnum = new.split(".", 1)[1] # "F.58" -> "58"
parent_code = f"{new[0]}.{catnum[0]}" # -> "F.5"
found_parent = _find_folder_by_code(newroot, parent_code, parent_kind)
if not found_parent:
_die(f"destination parent {parent_kind} {parent_code} does not exist — create it first")
newparentf = found_parent
old_base = os.path.basename(oldf) old_base = os.path.basename(oldf)
title = old_base.split(" ", 1)[1] if " " in old_base else "" title = old_base.split(" ", 1)[1] if " " in old_base else ""
new_base = f"{new} {title}".strip() new_base = f"{new} {title}".strip()
new_folder = os.path.join(newcatf, new_base) new_folder = os.path.join(newparentf, new_base)
old_stem, new_stem = old_base, new_base old_stem, new_stem = old_base, new_base
old_drel = os.path.relpath(oldf, vl.VAULT) old_drel = os.path.relpath(oldf, vl.VAULT)
new_drel = os.path.relpath(new_folder, vl.VAULT) new_drel = os.path.relpath(new_folder, vl.VAULT)
@@ -133,6 +242,7 @@ def op_move(argv, execute):
oldnote = os.path.join(oldf, old_base + ".md") oldnote = os.path.join(oldf, old_base + ".md")
if os.path.exists(oldnote): if os.path.exists(oldnote):
os.rename(oldnote, os.path.join(oldf, new_base + ".md")) os.rename(oldnote, os.path.join(oldf, new_base + ".md"))
os.makedirs(newparentf, exist_ok=True)
os.rename(oldf, new_folder) os.rename(oldf, new_folder)
olddata = os.path.join(vl.DATA, old_drel) olddata = os.path.join(vl.DATA, old_drel)
if os.path.isdir(olddata): if os.path.isdir(olddata):
@@ -198,6 +308,7 @@ def op_sync_data(argv, execute):
if len(made) > 40: if len(made) > 40:
print(f" ... and {len(made)-40} more") print(f" ... and {len(made)-40} more")
if execute: if execute:
_git_commit(f"vault sync-data ({len(made)} /data dir(s))", execute)
print("EXECUTED.") print("EXECUTED.")
else: else:
print("DRY-RUN — nothing changed. Add --execute.") print("DRY-RUN — nothing changed. Add --execute.")
+511
View File
@@ -0,0 +1,511 @@
#!/usr/bin/env python3
"""
vault_sync_daemon.py — long-running inotify (watchdog) daemon that keeps
/vault (JDex notes, git-backed) and /data (blob storage, Duplicati-backed)
structurally mirrored, in BOTH directions, for changes made by ANY tool
(Obsidian, SMB client, manual mv, not just the `vault` CLI).
Rules (confirmed with Ryan 2026-09-15):
FORWARD (/vault -> /data):
- rename/move of an ID folder in /vault -> mirror the rename in /data.
- delete of an ID folder in /vault -> do NOT delete /data's copy.
Instead: move the /data folder into the NEAREST existing `.09` archive
folder up the tree (category-level first, then area/system; create a
category-level `.09 Archive` in /data if none exists at any level yet),
and append a note to the nearest `.09` archive's /vault folder-note
markdown recording what was archived, when, and where.
REVERSE (/data -> /vault):
- a new folder appears in /data whose basename matches the `SYS.AC.ID `
Johnny Decimal pattern, with NO /vault counterpart yet (e.g. dropped in
over SMB) -> auto-create a minimal /vault stub folder + folder-note,
tagged "needs review", AND append a `#fix-me` line to the nearest `.01`
Inbox note in /vault (category-level first, then area/system, falling
back to the top-level System inbox).
- Folders inside any `.09`-pattern archive folder are never auto-stubbed
(archives are meant to hold retired stuff, not grow new tracked IDs) —
this also breaks the feedback loop from the forward-delete archive move.
- Generic non-ID subfolders (e.g. "Documents/" inside an existing ID
folder) are ignored entirely — normal blob organization, not JD structure.
Idempotent by construction: every action checks target state first, so a
duplicate/replayed event from the daemon's own writes is a safe no-op.
Run: python3 vault_sync_daemon.py (foreground; systemd unit wraps this)
Logs: /home/hermes/.hermes/logs/vault-sync-daemon.log
"""
import os
import re
import sys
import time
import shutil
import subprocess
import datetime
import threading
_SELF_DIR = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, _SELF_DIR)
import vault_lib as vl
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, DirCreatedEvent, DirMovedEvent, DirDeletedEvent
LOG_PATH = "/home/hermes/.hermes/logs/vault-sync-daemon.log"
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
NTFY_TOKEN_PATH = "/home/hermes/.hermes/secrets/ntfy-vault-token"
NTFY_URL = "https://ntfy.pandu.ski/vault"
def ntfy_post(title, message, tags="package", priority="default"):
"""Fire-and-forget ntfy notification. Never raises — a notify failure
must never block or crash the daemon's actual sync work."""
try:
token = open(NTFY_TOKEN_PATH, encoding="utf-8").read().strip()
except Exception as e:
log(f"ntfy_post: could not read token: {e}")
return
try:
subprocess.run(
["curl", "-fsS", "--max-time", "5", "-X", "POST",
"-H", f"Authorization: Bearer {token}",
"-H", f"Title: {title}",
"-H", f"Tags: {tags}",
"-H", f"Priority: {priority}",
"-d", message,
NTFY_URL],
check=False, capture_output=True, timeout=8,
)
except Exception as e:
log(f"ntfy_post failed: {e}")
VAULT = vl.VAULT
DATA = vl.DATA
SKIP_DIRS = vl.SKIP_DIRS
DATA_EXEMPT = vl.DATA_EXEMPT
ID_RE = vl.ID_RE
CAT_RE = vl.CAT_RE
ARCHIVE_ID_RE = re.compile(r'^([RJFI])\.(\d\d)\.09 ') # e.g. "F.25.09 Archive"
ARCHIVE_CAT_RE = re.compile(r'^([RJFI])\.(\d\d) ') # category folder, to find/create .09 inside
INBOX_ID_RE = re.compile(r'^([RJFI])\.(\d\d)\.01 ')
_lock = threading.Lock()
_recent_events = {} # (kind, path) -> timestamp, for debouncing duplicate inotify events
_DEBOUNCE_SECONDS = 3.0
def _debounced(kind, path):
"""
Return True (and record) if this (kind, path) event was NOT seen in the
last _DEBOUNCE_SECONDS — inotify/watchdog can emit duplicate CREATE
events for a single mkdir, so every handler must be idempotent AND
debounced to avoid double-processing (e.g. flagging the same new /data
folder in two different inbox notes).
"""
now = time.time()
key = (kind, path)
last = _recent_events.get(key)
_recent_events[key] = now
# opportunistic cleanup so this dict never grows unbounded
if len(_recent_events) > 2000:
cutoff = now - _DEBOUNCE_SECONDS
for k, v in list(_recent_events.items()):
if v < cutoff:
del _recent_events[k]
return last is None or (now - last) > _DEBOUNCE_SECONDS
def log(msg):
line = f"{datetime.datetime.now().isoformat(timespec='seconds')} {msg}"
print(line, flush=True)
try:
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass
def _git_commit(msg, paths=None):
"""
Commit only the specific paths the daemon itself touched — never a
blanket `git add -A`, so a human's concurrent in-progress edits
elsewhere in the vault are never swept into an unrelated auto-commit.
"""
if not paths:
return
subprocess.run(["git", "-C", VAULT, "add", "--"] + list(paths), check=False)
diff = subprocess.run(["git", "-C", VAULT, "diff", "--cached", "--quiet"], check=False)
if diff.returncode == 0:
return # nothing staged
subprocess.run(["git", "-C", VAULT, "commit", "-m", msg], check=False)
def _in_skip(path, root):
rel = os.path.relpath(path, root)
parts = rel.split(os.sep)
return any(p in SKIP_DIRS for p in parts)
def _under_archive(rel_path):
"""True if any path component matches the '<SYS.AC>.09 ' archive pattern."""
for part in rel_path.split(os.sep):
if ARCHIVE_ID_RE.match(part):
return True
return False
def _under_data_exempt(rel_path):
top = rel_path.split(os.sep)[0]
return top in DATA_EXEMPT
# ---------------------------------------------------------------------------
# FORWARD: /vault -> /data
# ---------------------------------------------------------------------------
def handle_vault_created(path):
"""
A new AREA/CATEGORY/ID-shaped folder appeared in /vault (e.g. created
via Obsidian, not the `vault` CLI) with no /data counterpart yet.
Mirror it: create the matching empty folder in /data. This is the
forward-create leg that was missing — only move/delete were wired up
originally, so a brand-new folder (like "R.6 Test") never propagated.
"""
if not os.path.isdir(path):
return
if _in_skip(path, VAULT):
return
if not _debounced("vault_created", path):
return
rel = os.path.relpath(path, VAULT)
base = os.path.basename(path)
if not (ID_RE.match(base) or CAT_RE.match(base) or vl.AREA_RE.match(base)):
return # not a JD-shaped folder — ignore (e.g. "Documents" subfolder)
data_path = os.path.join(DATA, rel)
if os.path.isdir(data_path):
return # already mirrored (duplicate event or pre-existing)
parent = os.path.dirname(data_path)
if not os.path.isdir(parent):
log(f"SKIP create mirror (parent missing in /data): {rel}")
return
os.makedirs(data_path, exist_ok=True)
log(f"MIRRORED create /data: {rel}")
ntfy_post(
"Vault sync: new folder",
f"Created in /vault, mirrored to /data: {rel}",
tags="sparkles",
)
def handle_vault_moved(src_path, dest_path):
if _in_skip(src_path, VAULT) or _in_skip(dest_path, VAULT):
return
rel_src = os.path.relpath(src_path, VAULT)
rel_dest = os.path.relpath(dest_path, VAULT)
data_src = os.path.join(DATA, rel_src)
data_dest = os.path.join(DATA, rel_dest)
if not os.path.isdir(data_src):
return # nothing to mirror
if os.path.exists(data_dest):
log(f"SKIP move mirror (dest exists): {rel_src} -> {rel_dest}")
return
os.makedirs(os.path.dirname(data_dest), exist_ok=True)
os.rename(data_src, data_dest)
log(f"MIRRORED rename /data: {rel_src} -> {rel_dest}")
ntfy_post(
"Vault sync: renamed",
f"/data: {rel_src} -> {rel_dest}",
tags="package",
)
def _category_title(catname):
"""'F.22 Fun lists' -> 'Fun lists'"""
m = CAT_RE.match(catname)
return catname[m.end():].strip() if m else catname
def _find_nearest_archive_dir(rel_path):
"""
Archive destination is ALWAYS the immediate owning category of the
deleted ID (one level up from the ID folder) — never a higher-level
ancestor's archive, even if one already exists there. If that category
has no '<SYS.AC>.09 ' archive folder yet, create it right there
(materializing the missing JD archive slot), matching Ryan's rule:
"create missing .01s and .09s when they're missing", not borrow one
further up the tree.
Returns (archive_data_path, vault_note_path_or_None).
vault_note_path is set only if this call just created a NEW archive
slot's folder-note (so the caller knows to git-add it).
"""
parts = rel_path.split(os.sep)
if len(parts) < 2:
return (DATA, None) # ID sits at top level (shouldn't happen)
cat_rel = os.sep.join(parts[:-1])
cat_vault = os.path.join(VAULT, cat_rel)
cat_data = os.path.join(DATA, cat_rel)
catname = os.path.basename(cat_vault)
m = CAT_RE.match(catname)
if not m or not os.path.isdir(cat_vault):
return (cat_data if os.path.isdir(cat_data) else DATA, None)
sysletter, catnum = m.group(1), m.group(2)
# already has an archive slot in this category?
if os.path.isdir(cat_vault):
for name in sorted(os.listdir(cat_vault)):
if ARCHIVE_ID_RE.match(name):
dpath = os.path.join(cat_data, name)
os.makedirs(dpath, exist_ok=True)
return (dpath, None)
# missing — create it in THIS category, not a higher one
archive_name = f"{sysletter}.{catnum}.09 Archive for {_category_title(catname)}"
vpath = os.path.join(cat_vault, archive_name)
vnote = os.path.join(vpath, archive_name + ".md")
created_note = None
if not os.path.exists(vnote):
os.makedirs(vpath, exist_ok=True)
with open(vnote, "w", encoding="utf-8") as f:
f.write(f"# {archive_name}\n\n"
f"Auto-created by vault_sync_daemon on "
f"{datetime.datetime.now().isoformat(timespec='seconds')} "
f"as the archive destination for a deleted ID in this category.\n")
log(f"CREATED category archive slot: {os.path.relpath(vpath, VAULT)}")
created_note = vnote
archive_path = os.path.join(cat_data, archive_name)
os.makedirs(archive_path, exist_ok=True)
return (archive_path, created_note)
def _nearest_archive_note(archive_data_path):
"""Given an archive dir in /data, return the matching /vault folder-note path, if any."""
rel = os.path.relpath(archive_data_path, DATA)
vault_dir = os.path.join(VAULT, rel)
if not os.path.isdir(vault_dir):
return None
note = os.path.join(vault_dir, os.path.basename(vault_dir) + ".md")
return note if os.path.exists(note) else None
def handle_vault_deleted(path):
if _in_skip(path, VAULT):
return
if not _debounced("vault_deleted", path):
return
rel = os.path.relpath(path, VAULT)
base = os.path.basename(path)
if not ID_RE.match(base):
return # only care about ID-folder deletions
data_path = os.path.join(DATA, rel)
if not os.path.isdir(data_path):
return # already handled (duplicate event) or no /data counterpart ever existed
archive_dir, created_note = _find_nearest_archive_dir(rel)
dest = os.path.join(archive_dir, base)
if os.path.exists(dest):
dest = os.path.join(archive_dir, f"{base} (archived {int(time.time())})")
shutil.move(data_path, dest)
log(f"ARCHIVED /data: {rel} -> {os.path.relpath(dest, DATA)}")
note = _nearest_archive_note(archive_dir)
ts = datetime.datetime.now().isoformat(timespec="seconds")
entry = (f"\n- **Archived {ts}:** `{base}` (deleted from /vault) — "
f"data preserved at `/data/{os.path.relpath(dest, DATA)}`\n")
commit_paths = []
if note:
with open(note, "a", encoding="utf-8") as f:
f.write(entry)
log(f"NOTED archive in: {os.path.relpath(note, VAULT)}")
commit_paths.append(note)
if created_note:
commit_paths.append(created_note)
_git_commit(f"vault_sync_daemon: archived {base}", commit_paths)
ntfy_post(
"Vault sync: archived",
f"Deleted from /vault: {base}\nData preserved at /data/{os.path.relpath(dest, DATA)}",
tags="package,wastebasket",
)
# ---------------------------------------------------------------------------
# REVERSE: /data -> /vault
# ---------------------------------------------------------------------------
def _find_nearest_inbox_note(rel_path):
"""
Inbox destination is ALWAYS the immediate owning category of the new
/data folder (same rule as archives: create the missing '.01 Inbox'
right there if it doesn't exist yet, never borrow one from a higher
ancestor).
"""
parts = rel_path.split(os.sep)
if len(parts) < 2:
return None
cat_rel = os.sep.join(parts[:-1])
cat_vault = os.path.join(VAULT, cat_rel)
cat_data = os.path.join(DATA, cat_rel)
catname = os.path.basename(cat_vault)
m = CAT_RE.match(catname)
if not m or not os.path.isdir(cat_vault):
return None
sysletter, catnum = m.group(1), m.group(2)
for name in sorted(os.listdir(cat_vault)):
if INBOX_ID_RE.match(name):
note = os.path.join(cat_vault, name, name + ".md")
if os.path.exists(note):
return note
# missing — create it in THIS category, not a higher one
inbox_name = f"{sysletter}.{catnum}.01 Inbox for {_category_title(catname)}"
vpath = os.path.join(cat_vault, inbox_name)
note = os.path.join(vpath, inbox_name + ".md")
os.makedirs(vpath, exist_ok=True)
with open(note, "w", encoding="utf-8") as f:
f.write(f"# {inbox_name}\n\n"
f"Auto-created by vault_sync_daemon on "
f"{datetime.datetime.now().isoformat(timespec='seconds')} "
f"to flag an unrecognized /data folder in this category.\n")
log(f"CREATED category inbox slot: {os.path.relpath(vpath, VAULT)}")
dpath = os.path.join(cat_data, inbox_name)
os.makedirs(dpath, exist_ok=True)
return note
def handle_data_created(path):
if not os.path.isdir(path):
return
if _in_skip(path, DATA):
return
if not _debounced("data_created", path):
return # duplicate inotify event for the same mkdir, ignore
rel = os.path.relpath(path, DATA)
if _under_data_exempt(rel):
return
if _under_archive(rel):
return # never auto-stub inside archive folders
base = os.path.basename(path)
if not ID_RE.match(base):
return # only ID-shaped folders trigger reverse-sync
vault_path = os.path.join(VAULT, rel)
if os.path.isdir(vault_path):
return # already has a /vault counterpart
catcode_root = os.path.dirname(vault_path)
if not os.path.isdir(catcode_root):
log(f"SKIP stub (parent category folder missing in /vault): {rel}")
return
note = os.path.join(vault_path, base + ".md")
os.makedirs(vault_path, exist_ok=True)
ts = datetime.datetime.now().isoformat(timespec="seconds")
with open(note, "w", encoding="utf-8") as f:
f.write(f"# {base}\n\n"
f"> [!warning] Needs review\n"
f"> Auto-created by vault_sync_daemon on {ts} because a matching "
f"folder appeared in `/data` with no `/vault` note yet.\n")
log(f"STUBBED /vault note for new /data folder: {rel}")
commit_paths = [note]
inbox = _find_nearest_inbox_note(rel)
if inbox:
with open(inbox, "a", encoding="utf-8") as f:
f.write(f"\n- #fix-me New `/data` folder detected with no vault note: "
f"[[{base}]] (auto-stub created {ts})\n")
log(f"FLAGGED in inbox: {os.path.relpath(inbox, VAULT)}")
commit_paths.append(inbox)
_git_commit(f"vault_sync_daemon: stubbed {base}", commit_paths)
ntfy_post(
"Vault sync: needs review",
f"New /data folder with no vault note: {rel}\nStub created, flagged in nearest inbox.",
tags="package,warning",
priority="high",
)
# ---------------------------------------------------------------------------
# watchdog wiring
# ---------------------------------------------------------------------------
class VaultHandler(FileSystemEventHandler):
def on_created(self, event):
if not event.is_directory:
return
with _lock:
try:
time.sleep(0.5) # let Obsidian finish writing the folder-note
handle_vault_created(event.src_path)
except Exception as e:
log(f"ERROR handle_vault_created: {e}")
ntfy_post(
"Vault sync ERROR",
f"handle_vault_created failed:\n{event.src_path}\n{e}",
tags="rotating_light",
priority="urgent",
)
def on_moved(self, event):
if not event.is_directory:
return
with _lock:
try:
handle_vault_moved(event.src_path, event.dest_path)
except Exception as e:
log(f"ERROR handle_vault_moved: {e}")
ntfy_post(
"Vault sync ERROR",
f"handle_vault_moved failed:\n{event.src_path} -> {event.dest_path}\n{e}",
tags="rotating_light",
priority="urgent",
)
def on_deleted(self, event):
if not event.is_directory:
return
with _lock:
try:
handle_vault_deleted(event.src_path)
except Exception as e:
log(f"ERROR handle_vault_deleted: {e}")
ntfy_post(
"Vault sync ERROR",
f"handle_vault_deleted failed:\n{event.src_path}\n{e}",
tags="rotating_light",
priority="urgent",
)
class DataHandler(FileSystemEventHandler):
def on_created(self, event):
if not event.is_directory:
return
with _lock:
try:
# small settle delay so a batch mkdir/copy isn't caught half-written
time.sleep(0.5)
handle_data_created(event.src_path)
except Exception as e:
log(f"ERROR handle_data_created: {e}")
ntfy_post(
"Vault sync ERROR",
f"handle_data_created failed:\n{event.src_path}\n{e}",
tags="rotating_light",
priority="urgent",
)
def main():
log("vault_sync_daemon starting")
observer = Observer()
observer.schedule(VaultHandler(), VAULT, recursive=True)
observer.schedule(DataHandler(), DATA, recursive=True)
observer.start()
log(f"watching {VAULT} and {DATA}")
ntfy_post("Vault sync daemon started", f"Watching {VAULT} and {DATA}", tags="rocket", priority="low")
try:
while True:
time.sleep(5)
except KeyboardInterrupt:
pass
finally:
observer.stop()
observer.join()
log("vault_sync_daemon stopped")
ntfy_post("Vault sync daemon stopped", "", tags="octagonal_sign", priority="high")
if __name__ == "__main__":
main()