vaultlint 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vaultlint contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: vaultlint
3
+ Version: 0.1.0
4
+ Summary: Consistency and staleness linter for Markdown vaults used as AI agent memory (Obsidian, CLAUDE.md, memory banks, agent knowledge bases).
5
+ Author: Automato
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/PLACEHOLDER/vaultlint
8
+ Project-URL: Repository, https://github.com/PLACEHOLDER/vaultlint
9
+ Keywords: agent,memory,markdown,vault,obsidian,claude,linter,consistency
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # vaultlint
23
+
24
+ Consistency and staleness linter for Markdown vaults used as **AI agent memory** — Obsidian vaults, `CLAUDE.md`/`AGENTS.md` setups, memory banks, agent knowledge bases.
25
+
26
+ Agents read these files and act on what they say. A duplicate "next action" marker, a broken cross-reference, or a status line that's quietly gone stale can make an agent act on the wrong thing — silently. `vaultlint` catches that class of bug before it does.
27
+
28
+ ## Why this exists
29
+
30
+ Existing tools cover adjacent ground but not this gap:
31
+ - `stalebrain` and `AgentLinter` audit a single instruction file (`CLAUDE.md`/`AGENTS.md`) against the repo.
32
+ - Generic Obsidian checkers (broken-link plugins, vault inspectors) target human PKM use, not the action-marker/state-consistency issues that specifically break an *agent* reading the vault.
33
+
34
+ `vaultlint` targets multi-file vaults with a history of decisions (the Obsidian/JARVIS pattern), checking for the exact class of bug that made this project necessary in the first place — see [Proof it works](#proof-it-works) below.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install vaultlint
40
+ ```
41
+
42
+ (Not yet published — see status note at the bottom of this README.)
43
+
44
+ ## Usage
45
+
46
+ ```bash
47
+ vaultlint /path/to/your/vault
48
+ ```
49
+
50
+ ```
51
+ vaultlint report — 75 files scanned, 40 findings
52
+
53
+ BROKEN_LINK: 37
54
+ DUPLICATE_ACTION_MARKER: 2
55
+ STALE_STATUS_CANDIDATE: 1
56
+
57
+ [WARNING] BROKEN_LINK — 03 - Automato/Opportunities/OPP-002-Autonomous-Digital-Business-Scan.md:12
58
+ reference to 'OPPORTUNITY_HUNTER.md' does not resolve to an existing file in the vault
59
+ ...
60
+ ```
61
+
62
+ Options:
63
+
64
+ ```bash
65
+ vaultlint /path/to/vault --json # machine-readable output
66
+ vaultlint /path/to/vault --stale-threshold-days 14 # tune staleness window (default: 30)
67
+ vaultlint /path/to/vault --fail-on error # exit 1 if any error-level finding exists (CI-friendly)
68
+ ```
69
+
70
+ Or without installing, straight from a checkout:
71
+
72
+ ```bash
73
+ python3 -m vaultlint /path/to/vault
74
+ ```
75
+
76
+ ## What it checks (v0.1 — 3 rules, zero dependencies, fully static)
77
+
78
+ 1. **BROKEN_LINK** — a Markdown `[text](path.md)` link or a backtick `` `path.md` `` reference that doesn't resolve to a real file in the vault.
79
+ 2. **DUPLICATE_ACTION_MARKER** — a single file contains more than one "next action" marker (e.g. `## Next Action` / `NEXT ACTION:`). An agent reading the file may act on the wrong one.
80
+ 3. **STALE_STATUS_CANDIDATE** — a `Status: ...` line reads as in-progress/pending in a file that hasn't been touched in N days. This is a heuristic (file mtime + keyword match), not a certainty — always flagged as `info`, meant for human review, not auto-action.
81
+
82
+ No LLM calls. No writes to your vault — read-only, always.
83
+
84
+ ## Proof it works
85
+
86
+ Run read-only against the real, actively-growing JARVIS Obsidian vault this tool was built inside of: **40 real findings across 75 files** as of the latest run (up from 18/59 a few hours earlier in this same project's history — the count grows naturally as the vault grows, which is itself evidence the tool stays useful over time, not a regression). The bulk are genuinely broken cross-references left behind after renames/reorganizations and short-name backtick references across subfolders (the documented v0.1 limitation below — some of these are true positives, some are the known false-positive class), plus 2 files that accumulated a duplicate `Next Action` marker — the exact failure mode that motivated this tool, since it happened for real, more than once, in this same project's history. Full raw output: [`dogfood-report.json`](./dogfood-report.json).
87
+
88
+ ## What v0.1 deliberately does NOT do
89
+
90
+ - No LLM-based semantic staleness judgment — only mtime + keyword heuristics. Cheaper, faster, more predictable. A v2 semantic mode is a possible future addition, not a v0.1 promise.
91
+ - Doesn't resolve short/ambiguous filename references (e.g. a backtick reference to `` `SCOPE.md` `` when it lives in a subfolder) — known limitation, see dogfood report.
92
+ - No git integration — uses file mtime as a universal proxy, since not every vault is a git repo.
93
+ - No auto-fix. `vaultlint` only reports; it never edits your vault.
94
+
95
+ ## License
96
+
97
+ MIT — see [LICENSE](./LICENSE).
98
+
99
+ ## Status
100
+
101
+ v0.1.0, locally built and tested (7/7 unit tests, dogfood-verified against a real 59-file vault). **Not yet published to PyPI or GitHub** — this repository/package is staged for release, pending publication approval.
@@ -0,0 +1,80 @@
1
+ # vaultlint
2
+
3
+ Consistency and staleness linter for Markdown vaults used as **AI agent memory** — Obsidian vaults, `CLAUDE.md`/`AGENTS.md` setups, memory banks, agent knowledge bases.
4
+
5
+ Agents read these files and act on what they say. A duplicate "next action" marker, a broken cross-reference, or a status line that's quietly gone stale can make an agent act on the wrong thing — silently. `vaultlint` catches that class of bug before it does.
6
+
7
+ ## Why this exists
8
+
9
+ Existing tools cover adjacent ground but not this gap:
10
+ - `stalebrain` and `AgentLinter` audit a single instruction file (`CLAUDE.md`/`AGENTS.md`) against the repo.
11
+ - Generic Obsidian checkers (broken-link plugins, vault inspectors) target human PKM use, not the action-marker/state-consistency issues that specifically break an *agent* reading the vault.
12
+
13
+ `vaultlint` targets multi-file vaults with a history of decisions (the Obsidian/JARVIS pattern), checking for the exact class of bug that made this project necessary in the first place — see [Proof it works](#proof-it-works) below.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install vaultlint
19
+ ```
20
+
21
+ (Not yet published — see status note at the bottom of this README.)
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ vaultlint /path/to/your/vault
27
+ ```
28
+
29
+ ```
30
+ vaultlint report — 75 files scanned, 40 findings
31
+
32
+ BROKEN_LINK: 37
33
+ DUPLICATE_ACTION_MARKER: 2
34
+ STALE_STATUS_CANDIDATE: 1
35
+
36
+ [WARNING] BROKEN_LINK — 03 - Automato/Opportunities/OPP-002-Autonomous-Digital-Business-Scan.md:12
37
+ reference to 'OPPORTUNITY_HUNTER.md' does not resolve to an existing file in the vault
38
+ ...
39
+ ```
40
+
41
+ Options:
42
+
43
+ ```bash
44
+ vaultlint /path/to/vault --json # machine-readable output
45
+ vaultlint /path/to/vault --stale-threshold-days 14 # tune staleness window (default: 30)
46
+ vaultlint /path/to/vault --fail-on error # exit 1 if any error-level finding exists (CI-friendly)
47
+ ```
48
+
49
+ Or without installing, straight from a checkout:
50
+
51
+ ```bash
52
+ python3 -m vaultlint /path/to/vault
53
+ ```
54
+
55
+ ## What it checks (v0.1 — 3 rules, zero dependencies, fully static)
56
+
57
+ 1. **BROKEN_LINK** — a Markdown `[text](path.md)` link or a backtick `` `path.md` `` reference that doesn't resolve to a real file in the vault.
58
+ 2. **DUPLICATE_ACTION_MARKER** — a single file contains more than one "next action" marker (e.g. `## Next Action` / `NEXT ACTION:`). An agent reading the file may act on the wrong one.
59
+ 3. **STALE_STATUS_CANDIDATE** — a `Status: ...` line reads as in-progress/pending in a file that hasn't been touched in N days. This is a heuristic (file mtime + keyword match), not a certainty — always flagged as `info`, meant for human review, not auto-action.
60
+
61
+ No LLM calls. No writes to your vault — read-only, always.
62
+
63
+ ## Proof it works
64
+
65
+ Run read-only against the real, actively-growing JARVIS Obsidian vault this tool was built inside of: **40 real findings across 75 files** as of the latest run (up from 18/59 a few hours earlier in this same project's history — the count grows naturally as the vault grows, which is itself evidence the tool stays useful over time, not a regression). The bulk are genuinely broken cross-references left behind after renames/reorganizations and short-name backtick references across subfolders (the documented v0.1 limitation below — some of these are true positives, some are the known false-positive class), plus 2 files that accumulated a duplicate `Next Action` marker — the exact failure mode that motivated this tool, since it happened for real, more than once, in this same project's history. Full raw output: [`dogfood-report.json`](./dogfood-report.json).
66
+
67
+ ## What v0.1 deliberately does NOT do
68
+
69
+ - No LLM-based semantic staleness judgment — only mtime + keyword heuristics. Cheaper, faster, more predictable. A v2 semantic mode is a possible future addition, not a v0.1 promise.
70
+ - Doesn't resolve short/ambiguous filename references (e.g. a backtick reference to `` `SCOPE.md` `` when it lives in a subfolder) — known limitation, see dogfood report.
71
+ - No git integration — uses file mtime as a universal proxy, since not every vault is a git repo.
72
+ - No auto-fix. `vaultlint` only reports; it never edits your vault.
73
+
74
+ ## License
75
+
76
+ MIT — see [LICENSE](./LICENSE).
77
+
78
+ ## Status
79
+
80
+ v0.1.0, locally built and tested (7/7 unit tests, dogfood-verified against a real 59-file vault). **Not yet published to PyPI or GitHub** — this repository/package is staged for release, pending publication approval.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vaultlint"
7
+ version = "0.1.0"
8
+ description = "Consistency and staleness linter for Markdown vaults used as AI agent memory (Obsidian, CLAUDE.md, memory banks, agent knowledge bases)."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Automato" }
14
+ ]
15
+ keywords = ["agent", "memory", "markdown", "vault", "obsidian", "claude", "linter", "consistency"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Software Development :: Quality Assurance",
23
+ "Topic :: Text Processing :: Markup :: Markdown",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.scripts]
28
+ vaultlint = "vaultlint.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/PLACEHOLDER/vaultlint"
32
+ Repository = "https://github.com/PLACEHOLDER/vaultlint"
33
+
34
+ [tool.setuptools]
35
+ packages = ["vaultlint"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,64 @@
1
+ """Plain-assert test suite for vaultlint (no external test framework — zero dependencies).
2
+ Run with: python3 tests/test_linter.py
3
+ """
4
+ import os
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10
+ from vaultlint import lint_vault # noqa: E402
11
+
12
+ FIXTURES = Path(__file__).resolve().parent / "fixtures"
13
+
14
+
15
+ def _findings_for(result, filename, rule=None):
16
+ out = [f for f in result["findings"] if f["file"].endswith(filename)]
17
+ if rule:
18
+ out = [f for f in out if f["rule"] == rule]
19
+ return out
20
+
21
+
22
+ def run():
23
+ failures = []
24
+
25
+ # make STALE_STATUS.md look 60 days old so the staleness rule can fire
26
+ stale_path = FIXTURES / "bad" / "STALE_STATUS.md"
27
+ sixty_days_ago = time.time() - 60 * 86400
28
+ os.utime(stale_path, (sixty_days_ago, sixty_days_ago))
29
+
30
+ result = lint_vault(str(FIXTURES), stale_threshold_days=30)
31
+
32
+ checks = [
33
+ ("clean file has zero findings",
34
+ len(_findings_for(result, "good/CLEAN_STATE.md")) == 0),
35
+ ("second clean file has zero findings",
36
+ len(_findings_for(result, "good/ANOTHER_GOOD_FILE.md")) == 0),
37
+ ("duplicate Next Action is caught",
38
+ len(_findings_for(result, "DUPLICATE_ACTION.md", "DUPLICATE_ACTION_MARKER")) >= 1),
39
+ ("broken markdown link is caught",
40
+ any("missing/plan.md" in f["message"] for f in _findings_for(result, "BROKEN_LINK.md", "BROKEN_LINK"))),
41
+ ("broken backtick reference is caught",
42
+ any("DOES_NOT_EXIST.md" in f["message"] for f in _findings_for(result, "BROKEN_LINK.md", "BROKEN_LINK"))),
43
+ ("stale in-progress status (60 days old) is caught",
44
+ len(_findings_for(result, "STALE_STATUS.md", "STALE_STATUS_CANDIDATE")) >= 1),
45
+ ("no false positive on terminal status in clean fixtures",
46
+ len(_findings_for(result, "good/", "STALE_STATUS_CANDIDATE")) == 0),
47
+ ]
48
+
49
+ for name, ok in checks:
50
+ status = "PASS" if ok else "FAIL"
51
+ print(f"[{status}] {name}")
52
+ if not ok:
53
+ failures.append(name)
54
+
55
+ print()
56
+ print(f"{len(checks) - len(failures)}/{len(checks)} checks passed")
57
+ if failures:
58
+ print("FAILED:", failures)
59
+ sys.exit(1)
60
+ sys.exit(0)
61
+
62
+
63
+ if __name__ == "__main__":
64
+ run()
@@ -0,0 +1,3 @@
1
+ from .linter import lint_vault, format_report
2
+
3
+ __all__ = ["lint_vault", "format_report"]
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+ import sys
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
@@ -0,0 +1,47 @@
1
+ """Command-line entry point for vaultlint."""
2
+ import argparse
3
+ import json
4
+ import sys
5
+
6
+ from .linter import lint_vault, format_report
7
+
8
+
9
+ def main(argv=None) -> int:
10
+ parser = argparse.ArgumentParser(
11
+ prog="vaultlint",
12
+ description="Scan a Markdown vault (agent memory / knowledge base) for staleness and consistency issues.",
13
+ )
14
+ parser.add_argument("path", nargs="?", default=".", help="vault root directory (default: current directory)")
15
+ parser.add_argument(
16
+ "--stale-threshold-days",
17
+ type=int,
18
+ default=30,
19
+ help="days without modification before an in-progress status is flagged as stale (default: 30)",
20
+ )
21
+ parser.add_argument("--json", action="store_true", help="print machine-readable JSON instead of the text report")
22
+ parser.add_argument(
23
+ "--fail-on",
24
+ choices=["error", "warning", "info", "never"],
25
+ default="never",
26
+ help="exit with status 1 if any finding at or above this severity exists (default: never)",
27
+ )
28
+ args = parser.parse_args(argv)
29
+
30
+ result = lint_vault(args.path, stale_threshold_days=args.stale_threshold_days)
31
+
32
+ if args.json:
33
+ print(json.dumps(result, indent=2))
34
+ else:
35
+ print(format_report(result))
36
+
37
+ if args.fail_on == "never":
38
+ return 0
39
+ severity_rank = {"info": 0, "warning": 1, "error": 2}
40
+ threshold = severity_rank[args.fail_on]
41
+ if any(severity_rank.get(f["severity"], 0) >= threshold for f in result["findings"]):
42
+ return 1
43
+ return 0
44
+
45
+
46
+ if __name__ == "__main__":
47
+ sys.exit(main())
@@ -0,0 +1,82 @@
1
+ """vaultlint core: scans a directory of Markdown files (an AI agent's memory/vault)
2
+ for staleness and consistency issues that could make an agent act on outdated state.
3
+ """
4
+ import json
5
+ import time
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+
9
+ from .rules import rule_broken_links, rule_duplicate_action_marker, rule_stale_status
10
+
11
+ DEFAULT_ACTION_MARKERS = [
12
+ ("Next Action heading", r"##\s*Next Action"),
13
+ ("NEXT ACTION label", r"NEXT ACTION\s*:"),
14
+ ]
15
+
16
+ DEFAULT_IGNORE_DIRS = {".obsidian", ".git", "node_modules", "__pycache__"}
17
+
18
+
19
+ def _collect_md_files(vault_root: Path, ignore_dirs: set):
20
+ files = []
21
+ for p in vault_root.rglob("*.md"):
22
+ if any(part in ignore_dirs for part in p.relative_to(vault_root).parts):
23
+ continue
24
+ files.append(p)
25
+ return files
26
+
27
+
28
+ def lint_vault(vault_root: str, stale_threshold_days: int = 30, ignore_dirs: set = None) -> dict:
29
+ vault_root = Path(vault_root).resolve()
30
+ ignore_dirs = ignore_dirs or DEFAULT_IGNORE_DIRS
31
+ md_files = _collect_md_files(vault_root, ignore_dirs)
32
+ rel_paths = {str(p.relative_to(vault_root)) for p in md_files}
33
+
34
+ now = time.time()
35
+ findings = []
36
+ for path in md_files:
37
+ rel = str(path.relative_to(vault_root))
38
+ try:
39
+ text = path.read_text(encoding="utf-8")
40
+ except (UnicodeDecodeError, OSError):
41
+ continue
42
+ mtime_days = (now - path.stat().st_mtime) / 86400.0
43
+
44
+ findings += rule_broken_links(rel, text, vault_root, rel_paths)
45
+ findings += rule_duplicate_action_marker(rel, text, DEFAULT_ACTION_MARKERS)
46
+ findings += rule_stale_status(rel, text, mtime_days, stale_threshold_days)
47
+
48
+ by_rule = {}
49
+ for f in findings:
50
+ by_rule.setdefault(f.rule, 0)
51
+ by_rule[f.rule] += 1
52
+
53
+ return {
54
+ "vault_root": str(vault_root),
55
+ "files_scanned": len(md_files),
56
+ "findings_count": len(findings),
57
+ "findings_by_rule": by_rule,
58
+ "findings": [asdict(f) for f in findings],
59
+ }
60
+
61
+
62
+ def format_report(result: dict) -> str:
63
+ lines = [
64
+ f"vaultlint report — {result['files_scanned']} files scanned, {result['findings_count']} findings",
65
+ "",
66
+ ]
67
+ for rule, count in sorted(result["findings_by_rule"].items()):
68
+ lines.append(f" {rule}: {count}")
69
+ lines.append("")
70
+ for f in result["findings"]:
71
+ lines.append(f"[{f['severity'].upper()}] {f['rule']} — {f['file']}:{f['line']}")
72
+ lines.append(f" {f['message']}")
73
+ return "\n".join(lines)
74
+
75
+
76
+ if __name__ == "__main__":
77
+ import sys
78
+ root = sys.argv[1] if len(sys.argv) > 1 else "."
79
+ result = lint_vault(root)
80
+ print(format_report(result))
81
+ print()
82
+ print(json.dumps(result, indent=2)[:0]) # keep import used; full json written by caller
@@ -0,0 +1,101 @@
1
+ """Detection rules for vaultlint. Each rule is pure: (path, text, ctx) -> list[Finding]."""
2
+ import re
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)")
7
+ BACKTICK_PATH_RE = re.compile(r"`([\w0-9 \-\/\.]+\.md)`")
8
+
9
+ IN_PROGRESS_KEYWORDS = [
10
+ "next", "planned", "not started", "em andamento", "todo", "pending",
11
+ "waiting", "in progress", "ready to start", "backlog",
12
+ ]
13
+ TERMINAL_KEYWORDS = [
14
+ "done", "completed", "concluído", "concluido", "reject", "kill",
15
+ "pass", "current", "histórico", "historico", "congelado", "accepted",
16
+ "rejected", "archived", "frozen", "revise",
17
+ ]
18
+
19
+
20
+ @dataclass
21
+ class Finding:
22
+ rule: str
23
+ file: str
24
+ line: int
25
+ message: str
26
+ severity: str = "warning"
27
+
28
+
29
+ def rule_broken_links(rel_path: str, text: str, vault_root: Path, all_files: set) -> list:
30
+ findings = []
31
+ lines = text.splitlines()
32
+ for lineno, line in enumerate(lines, start=1):
33
+ candidates = []
34
+ for m in MD_LINK_RE.finditer(line):
35
+ target = m.group(2)
36
+ if target.startswith(("http://", "https://", "#", "mailto:")):
37
+ continue
38
+ candidates.append(target)
39
+ for m in BACKTICK_PATH_RE.finditer(line):
40
+ candidates.append(m.group(1))
41
+ for target in candidates:
42
+ target_clean = target.split("#")[0].strip()
43
+ if not target_clean:
44
+ continue
45
+ if target_clean in all_files:
46
+ continue
47
+ # also try resolved relative to the referencing file's directory
48
+ file_dir_rel = str(Path(rel_path).parent / target_clean)
49
+ if file_dir_rel in all_files:
50
+ continue
51
+ findings.append(Finding(
52
+ rule="BROKEN_LINK",
53
+ file=rel_path,
54
+ line=lineno,
55
+ message=f"reference to '{target_clean}' does not resolve to an existing file in the vault",
56
+ ))
57
+ return findings
58
+
59
+
60
+ def rule_duplicate_action_marker(rel_path: str, text: str, marker_patterns: list) -> list:
61
+ findings = []
62
+ lines = text.splitlines()
63
+ for pattern_name, pattern in marker_patterns:
64
+ hits = [i + 1 for i, line in enumerate(lines) if re.match(pattern, line.strip(), re.IGNORECASE)]
65
+ if len(hits) > 1:
66
+ findings.append(Finding(
67
+ rule="DUPLICATE_ACTION_MARKER",
68
+ file=rel_path,
69
+ line=hits[0],
70
+ message=f"marker '{pattern_name}' appears {len(hits)} times (lines {hits}) — a file should have exactly one, or the agent may act on the wrong one",
71
+ severity="error",
72
+ ))
73
+ return findings
74
+
75
+
76
+ STATUS_LINE_RE = re.compile(r"(?:\*\*)?status(?:\*\*)?\s*[:\-]\s*(.+)", re.IGNORECASE)
77
+
78
+
79
+ def rule_stale_status(rel_path: str, text: str, mtime_days: float, stale_threshold_days: int) -> list:
80
+ findings = []
81
+ lines = text.splitlines()
82
+ for lineno, line in enumerate(lines, start=1):
83
+ m = STATUS_LINE_RE.search(line)
84
+ if not m:
85
+ continue
86
+ value = m.group(1).lower()
87
+ is_in_progress = any(k in value for k in IN_PROGRESS_KEYWORDS)
88
+ is_terminal = any(k in value for k in TERMINAL_KEYWORDS)
89
+ if is_in_progress and not is_terminal and mtime_days > stale_threshold_days:
90
+ findings.append(Finding(
91
+ rule="STALE_STATUS_CANDIDATE",
92
+ file=rel_path,
93
+ line=lineno,
94
+ message=(
95
+ f"status line looks in-progress/pending ('{m.group(1).strip()[:60]}') "
96
+ f"but file has not been modified in {mtime_days:.0f} days (threshold {stale_threshold_days}) "
97
+ f"— HEURISTIC, may be a deliberately preserved historical snapshot, needs human review"
98
+ ),
99
+ severity="info",
100
+ ))
101
+ return findings
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: vaultlint
3
+ Version: 0.1.0
4
+ Summary: Consistency and staleness linter for Markdown vaults used as AI agent memory (Obsidian, CLAUDE.md, memory banks, agent knowledge bases).
5
+ Author: Automato
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/PLACEHOLDER/vaultlint
8
+ Project-URL: Repository, https://github.com/PLACEHOLDER/vaultlint
9
+ Keywords: agent,memory,markdown,vault,obsidian,claude,linter,consistency
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # vaultlint
23
+
24
+ Consistency and staleness linter for Markdown vaults used as **AI agent memory** — Obsidian vaults, `CLAUDE.md`/`AGENTS.md` setups, memory banks, agent knowledge bases.
25
+
26
+ Agents read these files and act on what they say. A duplicate "next action" marker, a broken cross-reference, or a status line that's quietly gone stale can make an agent act on the wrong thing — silently. `vaultlint` catches that class of bug before it does.
27
+
28
+ ## Why this exists
29
+
30
+ Existing tools cover adjacent ground but not this gap:
31
+ - `stalebrain` and `AgentLinter` audit a single instruction file (`CLAUDE.md`/`AGENTS.md`) against the repo.
32
+ - Generic Obsidian checkers (broken-link plugins, vault inspectors) target human PKM use, not the action-marker/state-consistency issues that specifically break an *agent* reading the vault.
33
+
34
+ `vaultlint` targets multi-file vaults with a history of decisions (the Obsidian/JARVIS pattern), checking for the exact class of bug that made this project necessary in the first place — see [Proof it works](#proof-it-works) below.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install vaultlint
40
+ ```
41
+
42
+ (Not yet published — see status note at the bottom of this README.)
43
+
44
+ ## Usage
45
+
46
+ ```bash
47
+ vaultlint /path/to/your/vault
48
+ ```
49
+
50
+ ```
51
+ vaultlint report — 75 files scanned, 40 findings
52
+
53
+ BROKEN_LINK: 37
54
+ DUPLICATE_ACTION_MARKER: 2
55
+ STALE_STATUS_CANDIDATE: 1
56
+
57
+ [WARNING] BROKEN_LINK — 03 - Automato/Opportunities/OPP-002-Autonomous-Digital-Business-Scan.md:12
58
+ reference to 'OPPORTUNITY_HUNTER.md' does not resolve to an existing file in the vault
59
+ ...
60
+ ```
61
+
62
+ Options:
63
+
64
+ ```bash
65
+ vaultlint /path/to/vault --json # machine-readable output
66
+ vaultlint /path/to/vault --stale-threshold-days 14 # tune staleness window (default: 30)
67
+ vaultlint /path/to/vault --fail-on error # exit 1 if any error-level finding exists (CI-friendly)
68
+ ```
69
+
70
+ Or without installing, straight from a checkout:
71
+
72
+ ```bash
73
+ python3 -m vaultlint /path/to/vault
74
+ ```
75
+
76
+ ## What it checks (v0.1 — 3 rules, zero dependencies, fully static)
77
+
78
+ 1. **BROKEN_LINK** — a Markdown `[text](path.md)` link or a backtick `` `path.md` `` reference that doesn't resolve to a real file in the vault.
79
+ 2. **DUPLICATE_ACTION_MARKER** — a single file contains more than one "next action" marker (e.g. `## Next Action` / `NEXT ACTION:`). An agent reading the file may act on the wrong one.
80
+ 3. **STALE_STATUS_CANDIDATE** — a `Status: ...` line reads as in-progress/pending in a file that hasn't been touched in N days. This is a heuristic (file mtime + keyword match), not a certainty — always flagged as `info`, meant for human review, not auto-action.
81
+
82
+ No LLM calls. No writes to your vault — read-only, always.
83
+
84
+ ## Proof it works
85
+
86
+ Run read-only against the real, actively-growing JARVIS Obsidian vault this tool was built inside of: **40 real findings across 75 files** as of the latest run (up from 18/59 a few hours earlier in this same project's history — the count grows naturally as the vault grows, which is itself evidence the tool stays useful over time, not a regression). The bulk are genuinely broken cross-references left behind after renames/reorganizations and short-name backtick references across subfolders (the documented v0.1 limitation below — some of these are true positives, some are the known false-positive class), plus 2 files that accumulated a duplicate `Next Action` marker — the exact failure mode that motivated this tool, since it happened for real, more than once, in this same project's history. Full raw output: [`dogfood-report.json`](./dogfood-report.json).
87
+
88
+ ## What v0.1 deliberately does NOT do
89
+
90
+ - No LLM-based semantic staleness judgment — only mtime + keyword heuristics. Cheaper, faster, more predictable. A v2 semantic mode is a possible future addition, not a v0.1 promise.
91
+ - Doesn't resolve short/ambiguous filename references (e.g. a backtick reference to `` `SCOPE.md` `` when it lives in a subfolder) — known limitation, see dogfood report.
92
+ - No git integration — uses file mtime as a universal proxy, since not every vault is a git repo.
93
+ - No auto-fix. `vaultlint` only reports; it never edits your vault.
94
+
95
+ ## License
96
+
97
+ MIT — see [LICENSE](./LICENSE).
98
+
99
+ ## Status
100
+
101
+ v0.1.0, locally built and tested (7/7 unit tests, dogfood-verified against a real 59-file vault). **Not yet published to PyPI or GitHub** — this repository/package is staged for release, pending publication approval.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ tests/test_linter.py
5
+ vaultlint/__init__.py
6
+ vaultlint/__main__.py
7
+ vaultlint/cli.py
8
+ vaultlint/linter.py
9
+ vaultlint/rules.py
10
+ vaultlint.egg-info/PKG-INFO
11
+ vaultlint.egg-info/SOURCES.txt
12
+ vaultlint.egg-info/dependency_links.txt
13
+ vaultlint.egg-info/entry_points.txt
14
+ vaultlint.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vaultlint = vaultlint.cli:main
@@ -0,0 +1 @@
1
+ vaultlint