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.
- vaultlint-0.1.0/LICENSE +21 -0
- vaultlint-0.1.0/PKG-INFO +101 -0
- vaultlint-0.1.0/README.md +80 -0
- vaultlint-0.1.0/pyproject.toml +35 -0
- vaultlint-0.1.0/setup.cfg +4 -0
- vaultlint-0.1.0/tests/test_linter.py +64 -0
- vaultlint-0.1.0/vaultlint/__init__.py +3 -0
- vaultlint-0.1.0/vaultlint/__main__.py +5 -0
- vaultlint-0.1.0/vaultlint/cli.py +47 -0
- vaultlint-0.1.0/vaultlint/linter.py +82 -0
- vaultlint-0.1.0/vaultlint/rules.py +101 -0
- vaultlint-0.1.0/vaultlint.egg-info/PKG-INFO +101 -0
- vaultlint-0.1.0/vaultlint.egg-info/SOURCES.txt +14 -0
- vaultlint-0.1.0/vaultlint.egg-info/dependency_links.txt +1 -0
- vaultlint-0.1.0/vaultlint.egg-info/entry_points.txt +2 -0
- vaultlint-0.1.0/vaultlint.egg-info/top_level.txt +1 -0
vaultlint-0.1.0/LICENSE
ADDED
|
@@ -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.
|
vaultlint-0.1.0/PKG-INFO
ADDED
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vaultlint
|