Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ real entries from — under the same gate as any other capture, evidence read fr
`git show` first.

**Freshness.** Code changes; learnings shouldn't silently rot. `/lore:lint` checks
that each entry's `files:` still exist; `--report` ranks entries whose referenced code
that each entry's `files:` still exist and that its `status:` and dates are values the
hooks can read (a typo'd `superseeded` would be served as live guidance); `--report` ranks entries whose referenced code
changed since they were last `verified:` (your re-verify worklist — computed in a single
streaming `git log` pass, not one subprocess per file); `--dupes` finds near-duplicate
entry pairs (e.g. two teammates capturing the same gotcha on parallel branches) and
Expand Down Expand Up @@ -268,6 +269,9 @@ instead of the shared store):
```

`/lore:stats` counts personal entries so a two-store setup never looks half-empty.
A personal store **inside** the repo is private only while `.gitignore` covers it:
`/lore:lint` and `/lore:stats` warn when git would publish it (not ignored, or
files already committed).
To make the **whole** store private instead, just add your store directory (e.g.
`learnings/`) to `.gitignore`.

Expand Down
5 changes: 3 additions & 2 deletions plugin/commands/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ PY=$(command -v python3 || command -v python || command -v py)
"$PY" "${CLAUDE_PLUGIN_ROOT}/scripts/verify_refs.py" $ARGUMENTS
```

Modes: no args = file-reference existence check (plus a note about any `.lore.json`
keys the hooks ignore — typos, wrong types) · `--report` = git drift triage
Modes: no args = file-reference existence check, plus `status:`/`date:`/`verified:`
values the hooks would misread (a typo'd status is served as live guidance), plus a
note about any `.lore.json` keys the hooks ignore — typos, wrong types · `--report` = git drift triage
(entries whose code changed since they were verified) · `--dupes` = near-duplicate
triage (entry pairs with overlapping title/tags, e.g. parallel captures from
teammates, plus category-name variants) · `--index` = regenerate the store README ·
Expand Down
28 changes: 25 additions & 3 deletions plugin/scripts/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,30 @@ def _unquote(s):
return s


def _strip_comment(s):
"""`s` without a trailing YAML comment: a `#` that opens the value or
follows whitespace, outside quotes -- so `C#` and `"see #12"` keep theirs."""
if "#" not in s: # the common case; this runs per line on every prompt
return s
quote = None
for i, c in enumerate(s):
if quote:
if c == quote:
quote = None
elif c in "\"'" and (i == 0 or s[i - 1] in " \t[,"):
quote = c
elif c == "#" and (i == 0 or s[i - 1] in " \t"):
return s[:i].rstrip()
return s


def parse_frontmatter(text):
"""Parse the leading `--- ... ---` YAML-ish block (simple subset). Returns dict."""
"""Parse the leading `--- ... ---` YAML-ish block (simple subset). Returns dict.

Trailing `# comments` are dropped as YAML does -- the documented entry
format carries them, and a comment kept in the value turned
`status: superseded # ...` into an unknown, i.e. LIVE, status.
"""
m = re.match(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", text, re.S)
if not m:
return {}
Expand All @@ -207,14 +229,14 @@ def parse_frontmatter(text):
if not km:
i += 1
continue
key, rest = km.group(1), km.group(2).strip()
key, rest = km.group(1), _strip_comment(km.group(2).strip())
if rest == "":
# block list: subsequent ` - item` lines
vals, j = [], i + 1
while j < len(lines):
lm = re.match(r"^\s*-\s*(.+)$", lines[j])
if lm:
vals.append(_unquote(lm.group(1)))
vals.append(_unquote(_strip_comment(lm.group(1))))
j += 1
else:
break
Expand Down
102 changes: 87 additions & 15 deletions plugin/scripts/verify_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@

Modes:
(default) Existence check -- flag entries whose frontmatter `files:` paths no
longer exist. `--strict` exits 1 on ACTIONABLE issues (a missing
file on a non-stale entry; missing files on superseded entries are
expected/informational). Also reports `.lore.json` keys the hooks
silently ignore (a typo like `maxRecal`), which is invisible at
hook time by design.
longer exist, and live entries whose `status:`/`date:`/`verified:`
the hooks would misread. `--strict` exits 1 on ACTIONABLE issues (a
missing file or a bad value on a non-stale entry; missing files on
superseded entries are expected/informational). Also reports
`.lore.json` keys the hooks silently ignore (a typo like
`maxRecal`), which is invisible at hook time by design.
--report Drift triage -- for each current entry, use `git log` to find
referenced files changed AFTER the entry's `verified:` (or `date:`)
baseline, ranked by gap. Best candidates for a re-verify. Heuristic.
Expand Down Expand Up @@ -123,17 +124,83 @@ def config_warning(project):
return "note: .lore.json -- ignored: " + "; ".join(bits) + "."


# --- default mode: file-ref existence check ----------------------------------
def personal_store_warning(project, cfg):
"""Warn when git would publish the PRIVATE store, or None.

A `personalStoreDir` inside the repository (`.lore/personal`) is private
only while .gitignore covers it, and /lore:init adds that rule only when
it creates the directory: a hand-set key, or a rule removed later, leaves
someone's working preferences one `git add -A` away from a PR. Nothing
else would say so -- the personal store is never secret-scanned or
linted, because it is supposed to never be pushed.
"""
store = personal_store(project, cfg)
if store is None or not store.is_dir():
return None
rel = rel_path(project, store)
if Path(rel).is_absolute():
return None # outside the repository: git never sees it
try:
tracked = subprocess.run(
["git", "-C", str(project), "ls-files", "--", rel],
capture_output=True, text=True, timeout=15)
ignored = subprocess.run(
["git", "-C", str(project), "check-ignore", "-q", rel],
capture_output=True, timeout=15)
except (OSError, subprocess.SubprocessError):
return None
if tracked.returncode != 0 or ignored.returncode not in (0, 1):
return None # not a git repository, or git failed: nothing to say
if tracked.stdout.strip():
return (f"warning: personalStoreDir '{rel}' has files committed to "
f"git -- they are published. `git rm -r --cached {rel}`, then "
f"add '{rel}/' to .gitignore.")
if ignored.returncode == 1:
return (f"warning: personalStoreDir '{rel}' is inside the repository "
f"and not gitignored -- `git add -A` would publish it. Add "
f"'{rel}/' to .gitignore.")
return None


# --- default mode: file-ref existence + frontmatter value check --------------

def _value_problems(e, cfg):
"""Frontmatter values the hooks silently misread, as messages.

A `status:` that is neither `current` nor a stale status is served by
recall as LIVE guidance -- a typo'd `superseeded` resurrects a retired
entry. A `date:`/`verified:` that is not YYYY-MM-DD switches off the
entry's freshness signals (age flag, drift triage) without a word.
"""
problems = []
known = ["current"] + list(cfg["staleStatuses"])
if e["status"] not in known:
problems.append(f"unknown status '{e['status']}' -- recall reads it as "
f"current; use one of: {', '.join(known)}")
for key in ("date", "verified"):
value = e[key]
if value and not (_DATE_LINE.match(value) and _parse_date(value)):
problems.append(f"{key} '{value}' is not a YYYY-MM-DD date -- "
"freshness checks skip it")
return problems


def cmd_check(entries, project, cfg, strict):
stale_set = set(cfg["staleStatuses"])
issues = []
issues, bad_values, missing_refs = [], False, False
for e in entries:
actionable = e["status"] not in stale_set
if actionable: # retired entries drive no guidance and no freshness
for problem in _value_problems(e, cfg):
issues.append((e, problem, True))
bad_values = True
for ref in e["files"]:
if ref and not ref_exists(project, ref):
issues.append((e, ref, actionable))
notes = [n for n in (config_warning(project), version_warning(project)) if n]
issues.append((e, f"referenced file no longer exists: {ref}",
actionable))
missing_refs = True
notes = [n for n in (config_warning(project), version_warning(project),
personal_store_warning(project, cfg)) if n]
if not issues:
if entries:
print("OK learnings: all entries have valid frontmatter file refs.")
Expand All @@ -148,17 +215,21 @@ def cmd_check(entries, project, cfg, strict):
print(f" {n}")
return 0
actionable = [i for i in issues if i[2]]
print(f"\nlearnings file-ref check: {len(issues)} issue(s), {len(actionable)} actionable:")
print(f"\nlearnings check: {len(issues)} issue(s), {len(actionable)} actionable:")
cur = None
for e, ref, act in issues:
for e, problem, act in issues:
rel = e["path"].relative_to(project).as_posix()
if rel != cur:
print(f"\n {rel} [status: {e['status'] or 'current'}]")
cur = rel
note = "" if act else f" (expected -- entry is {e['status']})"
print(f" - referenced file no longer exists: {ref}{note}")
print("\n -> A 'current' entry with a missing file is likely STALE: fix the path,")
print(" mark status: superseded, or re-verify the claim against the code.")
print(f" - {problem}{note}")
if missing_refs:
print("\n -> A 'current' entry with a missing file is likely STALE: fix the path,")
print(" mark status: superseded, or re-verify the claim against the code.")
if bad_values:
print("\n -> Fix the value: a typo'd status is served as live guidance, and a")
print(" malformed date silently turns off the entry's freshness checks.")
for n in notes:
print(f" {n}")
return 1 if (strict and actionable) else 0
Expand Down Expand Up @@ -504,7 +575,8 @@ def counts(d):
tail = " -> verify_refs.py --dupes" if dupes else ""
print(f" near-duplicate pairs (title/tag overlap): {dupes}{tail}")
print(f" links: dangling [[refs]] (soft; forward-refs ok): {len(dangling)}")
for n in (config_warning(project), version_warning(project)):
for n in (config_warning(project), version_warning(project),
personal_store_warning(project, cfg)):
if n:
print(f"\n {n}")
return 0
Expand Down
5 changes: 3 additions & 2 deletions plugin/templates/lore-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ jobs:
run: python .lore/scan_secrets.py

# Freshness. --strict fails when a `current` entry points at a file that
# no longer exists, which is the cheapest possible staleness signal.
# Drop --strict to make this advisory instead of blocking.
# no longer exists, which is the cheapest possible staleness signal, or
# has a status/date the hooks would misread (a typo'd status is served
# as live guidance). Drop --strict to make this advisory, not blocking.
- name: Freshness check
run: python .lore/verify_refs.py --strict
38 changes: 38 additions & 0 deletions tests/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,44 @@ def test_empty_list_and_missing_block(self):
self.assertEqual(parse_frontmatter("---\ntags: []\n---\n")["tags"], [])
self.assertEqual(parse_frontmatter("no frontmatter here"), {})

def test_trailing_comments_are_dropped(self):
# They used to become part of the value: `superseded # replaced`
# was not a stale status, so recall served a dead entry as live.
fm = parse_frontmatter(
"---\n"
"status: superseded # replaced by the v2 cursor\n"
"verified: 2026-01-01 # bump after a re-verify\n"
"files: [src/a.py] # exact path or glob (src/*.py)\n"
"tags: # none yet\n"
"---\n")
self.assertEqual(fm["status"], "superseded")
self.assertEqual(fm["verified"], "2026-01-01")
self.assertEqual(fm["files"], ["src/a.py"])
self.assertEqual(fm["tags"], [])

def test_hash_inside_a_value_is_kept(self):
fm = parse_frontmatter(
"---\n"
'title: "Why #1 wins" # note\n'
"category: C#\n"
'tags: [a, "b # c"]\n'
"files:\n - src/x.py # the entry point\n"
"---\n")
self.assertEqual(fm["title"], "Why #1 wins")
self.assertEqual(fm["category"], "C#")
self.assertEqual(fm["tags"], ["a", "b # c"])
self.assertEqual(fm["files"], ["src/x.py"])

def test_documented_example_parses_as_documented(self):
readme = (Path(__file__).resolve().parents[1] / "README.md").read_text(
encoding="utf-8")
example = readme.split("```markdown\n", 1)[1].split("```", 1)[0]
fm = parse_frontmatter(example)
self.assertEqual(fm["track"], "knowledge")
self.assertEqual(fm["files"], [".github/workflows/ci.yml"])
self.assertEqual(fm["status"], "current")
self.assertEqual(fm["verified"], "2026-01-01")


class TestLoadConfig(unittest.TestCase):
def _cfg(self, payload):
Expand Down
96 changes: 96 additions & 0 deletions tests/test_verify_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,46 @@ def test_silent_when_clean(self):
self.assertIsNone(verify_refs.config_warning(Path(td)))


class TestFrontmatterValues(unittest.TestCase):
"""Values the hooks silently misread: a typo'd status, a non-ISO date."""

def _check(self, cfg=None, **entry):
td = tempfile.TemporaryDirectory()
self.addCleanup(td.cleanup)
proj = Path(td.name)
write_entry(proj / "learnings", "x/e.md", title="E", **entry)
out = io.StringIO()
with contextlib.redirect_stdout(out):
rc = verify_refs.cmd_check(list(iter_entries(proj / "learnings")),
proj, cfg or dict(DEFAULTS), True)
return rc, out.getvalue()

def test_typoed_status_is_actionable(self):
# Not a stale status, so recall serves the entry as LIVE guidance.
rc, out = self._check(status="superseeded")
self.assertEqual(rc, 1)
self.assertIn("unknown status 'superseeded'", out)

def test_unparseable_dates_are_actionable(self):
rc, out = self._check(date="last week", verified="2026-02-30")
self.assertEqual(rc, 1)
self.assertIn("date 'last week'", out)
self.assertIn("verified '2026-02-30'", out)

def test_valid_values_are_clean(self):
rc, out = self._check(date="2026-01-01", verified="2026-02-01")
self.assertEqual(rc, 0)
self.assertIn("OK", out)

def test_configured_stale_status_is_valid(self):
cfg = dict(DEFAULTS, staleStatuses=["archived"])
self.assertEqual(self._check(cfg=cfg, status="archived")[0], 0)

def test_stale_entries_are_not_value_checked(self):
# Retired entries drive no live guidance and no freshness flags.
self.assertEqual(self._check(status="superseded", date="someday")[0], 0)


class TestIndex(unittest.TestCase):
def test_writes_grouped_index(self):
with tempfile.TemporaryDirectory() as td:
Expand Down Expand Up @@ -293,6 +333,62 @@ def test_populated_team_store_message_unchanged(self):
self.assertIn("all entries have valid frontmatter file refs", text)


@unittest.skipUnless(GIT, "git not available")
class TestPersonalStorePublication(unittest.TestCase):
"""An in-repo personal store stays private only while git ignores it."""

def _git(self, proj, *args):
subprocess.run(
[GIT, "-C", str(proj), "-c", "user.email=t@t", "-c",
"user.name=t", *args],
check=True, capture_output=True)

def _project(self, gitignore=""):
td = tempfile.TemporaryDirectory()
self.addCleanup(td.cleanup)
proj = Path(td.name)
self._git(proj, "init", "-q")
write_entry(proj / "notes", "prefs/diff.md", title="Show the diff first")
if gitignore:
(proj / ".gitignore").write_text(gitignore, encoding="utf-8")
return proj

def _warn(self, proj, personal="notes"):
return verify_refs.personal_store_warning(
proj, dict(DEFAULTS, personalStoreDir=personal))

def test_unignored_store_is_flagged(self):
self.assertIn("not gitignored", self._warn(self._project()) or "")

def test_ignored_store_is_silent(self):
self.assertIsNone(self._warn(self._project(gitignore="notes/\n")))

def test_committed_store_is_flagged_even_once_ignored(self):
proj = self._project(gitignore="notes/\n")
self._git(proj, "add", "-f", "notes")
self._git(proj, "commit", "-q", "-m", "oops")
self.assertIn("committed", self._warn(proj) or "")

def test_store_outside_the_repository_is_not_checked(self):
outside = tempfile.TemporaryDirectory()
self.addCleanup(outside.cleanup)
self.assertIsNone(self._warn(self._project(), personal=outside.name))

def test_lint_and_stats_print_it(self):
proj = self._project()
(proj / ".lore.json").write_text(
json.dumps({"personalStoreDir": "notes"}), encoding="utf-8")
for argv in ([], ["--stats"]):
out = io.StringIO()
with self.subTest(argv=argv), \
mock.patch.dict(os.environ,
{"CLAUDE_PROJECT_DIR": str(proj)}), \
mock.patch("sys.argv", ["verify_refs.py", *argv]), \
contextlib.redirect_stdout(out):
self.assertEqual(verify_refs.main(), 0)
self.assertIn("personalStoreDir 'notes'", out.getvalue())


@unittest.skipUnless(GIT, "git not available")
class TestGitDrift(unittest.TestCase):
def _git(self, proj, *args):
Expand Down
Loading