From e0870ff1a028a98f1a390a0866b29a33678d6feb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 05:00:14 +0000 Subject: [PATCH 1/3] fix: inline YAML comments leaked into frontmatter values The README documents the entry format with trailing comments: status: current # or: superseded / obsolete verified: 2026-01-01 # optional; bump after a re-verify ... files: [.github/workflows/ci.yml] # exact path, dir prefix ... The frontmatter parser kept them as part of the value, so an entry written that way parsed as status -> 'current # or: superseded / obsolete' verified -> '2026-01-01 # optional; bump after a re-verify ...' files -> '[.github/workflows/ci.yml] # exact path, ...' (one string) and each field then misbehaved quietly: - status: anything not in staleStatuses is live, so `status: superseded # replaced by X` served a dead entry as LIVE guidance. - dates: recall's age flag parses the whole value and gave up, so the "verified N months ago" warning vanished (the linter happened to tolerate it, so the two disagreed about the same entry). - files: the list collapsed into one bogus glob -- edit-time recall never matched the file and the linter reported it as missing. Drop a trailing `# comment` as YAML does: a `#` that opens the value or follows whitespace, outside quotes. `C#`, `"Why #1 wins"` and `"b # c"` in a list keep theirs. One behavior change follows YAML too: an UNQUOTED value with ` #` in it (`title: Fix issue #12`) now ends before the `#`; quote it to keep it -- the documented examples already quote titles. A `'#' not in line` fast path keeps the hooks at their previous latency (measured at 2,000 entries). Tests: comments on scalars, lists, block-list items and an empty key; `#` inside quoted and unquoted values kept; and the README's documented example must parse to the values it documents. All three fail without the change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DMXx6BJswH6xiwdqrwQnEf --- plugin/scripts/_common.py | 28 +++++++++++++++++++++++++--- tests/test_common.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/plugin/scripts/_common.py b/plugin/scripts/_common.py index 2154211..acdd447 100644 --- a/plugin/scripts/_common.py +++ b/plugin/scripts/_common.py @@ -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 {} @@ -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 diff --git a/tests/test_common.py b/tests/test_common.py index 108b408..b221615 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -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): From 638ef5e08b9f5e8fb3c1ba9d781c0f00b3a4c6ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 05:01:29 +0000 Subject: [PATCH 2/3] fix: the linter passed entries whose status or dates the hooks misread `/lore:lint --strict` checked only that `files:` exist, so values the hooks silently misinterpret went through clean: status: superseeded recall: not a stale status -> LIVE guidance date: last week no age flag, no drift triage -- silently verified: 2026-02-30 same --strict exit 0, "all entries have valid frontmatter" The status typo is the dangerous one: the entry was retired on purpose, and recall resurrects it as current guidance with no [SUPERSEDED] marker. The default mode now also reports, for live entries, a `status:` that is neither `current` nor one of `staleStatuses` (the message names the valid values, config-defined ones included) and a `date:`/`verified:` that is not a real YYYY-MM-DD date. Both are actionable, so `--strict` -- the pre-push check and the CI guard -- fails on them. Retired entries are not value- checked: they drive no live guidance and no freshness flags. The report header drops "file-ref" since it now covers both, and each kind of problem gets its own advice line. This depends on the previous commit: without it, every entry written in the README's documented format (trailing `# comments`) would be flagged here. Behavior change for CI: a store with such values will fail `--strict` once the project refreshes its `.lore/` copies (re-running /lore:init). Tests: a typo'd status and bad dates fail --strict (both fail without the change); valid values, a config-defined stale status, and a retired entry with a bad date stay clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DMXx6BJswH6xiwdqrwQnEf --- README.md | 3 +- plugin/commands/lint.md | 5 +-- plugin/scripts/verify_refs.py | 58 +++++++++++++++++++++++++++-------- plugin/templates/lore-ci.yml | 5 +-- tests/test_verify_refs.py | 40 ++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0a7203a..77c9c57 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/plugin/commands/lint.md b/plugin/commands/lint.md index 7267f2f..f9c2459 100644 --- a/plugin/commands/lint.md +++ b/plugin/commands/lint.md @@ -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 · diff --git a/plugin/scripts/verify_refs.py b/plugin/scripts/verify_refs.py index dfce7d7..5b6e984 100644 --- a/plugin/scripts/verify_refs.py +++ b/plugin/scripts/verify_refs.py @@ -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. @@ -123,16 +124,43 @@ def config_warning(project): return "note: .lore.json -- ignored: " + "; ".join(bits) + "." -# --- default mode: file-ref existence check ---------------------------------- +# --- 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)) + 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)) if n] if not issues: if entries: @@ -148,17 +176,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 diff --git a/plugin/templates/lore-ci.yml b/plugin/templates/lore-ci.yml index bfb410e..186f20d 100644 --- a/plugin/templates/lore-ci.yml +++ b/plugin/templates/lore-ci.yml @@ -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 diff --git a/tests/test_verify_refs.py b/tests/test_verify_refs.py index 266c9b9..54df5d2 100644 --- a/tests/test_verify_refs.py +++ b/tests/test_verify_refs.py @@ -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: From db6068db51b14f01f03b42d588cd8903e2fc7e7b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 05:02:32 +0000 Subject: [PATCH 3/3] fix: nothing warned when git would publish the personal store `personalStoreDir` may resolve inside the repository (this repo uses `.lore/personal`). It is then private only while .gitignore covers it, and /lore:init adds that rule only when it creates the directory. Set the key by hand, or drop the rule later, and the store is one `git add -A` away from a PR -- with no warning anywhere: .lore.json {"personalStoreDir": "notes"} /lore:lint OK learnings: all entries have valid frontmatter file refs. git status ?? notes/ The personal store is exempt from the secret scan and the linter precisely because it is never supposed to be pushed, so this is the one check that keeps that assumption true. It holds preferences about how a person likes to work -- the content the two-store split exists to keep out of teammates' PRs. `/lore:lint` and `/lore:stats` now add a note when an in-repo personal store has files committed to git (published already, even if ignored since: the note gives the `git rm -r --cached` fix) or is not ignored. A store outside the repository, or no git, stays silent. It is a note, not a `--strict` failure; the pre-push hook prints it on every push. Tests (with git): unignored and committed stores are flagged, an ignored one and one outside the repository are not, and both commands print it. The lint/stats test fails without the change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DMXx6BJswH6xiwdqrwQnEf --- README.md | 3 ++ plugin/scripts/verify_refs.py | 44 +++++++++++++++++++++++++-- tests/test_verify_refs.py | 56 +++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 77c9c57..56be7eb 100644 --- a/README.md +++ b/README.md @@ -269,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`. diff --git a/plugin/scripts/verify_refs.py b/plugin/scripts/verify_refs.py index 5b6e984..5bd8a68 100644 --- a/plugin/scripts/verify_refs.py +++ b/plugin/scripts/verify_refs.py @@ -124,6 +124,44 @@ def config_warning(project): return "note: .lore.json -- ignored: " + "; ".join(bits) + "." +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): @@ -161,7 +199,8 @@ def cmd_check(entries, project, cfg, strict): 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)) if n] + 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.") @@ -536,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 diff --git a/tests/test_verify_refs.py b/tests/test_verify_refs.py index 54df5d2..5c775cf 100644 --- a/tests/test_verify_refs.py +++ b/tests/test_verify_refs.py @@ -333,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):