Repository navigation
Conversation
…ntel#728) Reads a Claude Code auto-memory directory (MEMORY.md, topic files, archive/) and reports deterministic findings: dead index links, the 200-line / 25KB load limit, orphans, frontmatter, types, duplicate names, dangling wikilinks, undated relative dates, stray separators. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…ive and date false positives Answers the local self-review: a titled or <> link resolves, a target outside the directory or a directory is dead, the size is read on disk, archive/ is not checked, a dated line or code span is no relative date. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
CLI interface change detectedThis PR changes dokimi's user-facing CLI surface (commands and/or options): + added command `dokimi memory`
+ option `--help/-h`
+ added command `dokimi memory lint`
+ argument `memory_dir`
+ option `--fail-on`
+ option `--help/-h`
+ option `--json`Updated on every push; the |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved defects allow links to escape the memory directory through symlinks, reject valid parenthesized filenames, and silently accept an unreadable MEMORY.md.
3 open issues
What changed in this PR
3 new findings.
Adds dokimi memory lint <dir>, the first command under the memory subject introduced in #728. It checks Claude Code auto-memory directories deterministically, without model calls or network access.
Changes:
- Reads the index, topic files, YAML frontmatter, and archive; checks links, size limits, metadata, and relative dates.
- Adds text and JSON reports with severity-based exit codes.
- Integrates CLI routing, offline tests, documentation, and a changelog fragment.
| File | Description |
|---|---|
dokimi/tests/test_memory_lint.py |
Tests reader behavior, lint rules, and CLI output. |
dokimi/tests/test_import_architecture.py |
Assigns memory to the tracks layer. |
dokimi/tests/test_cli_routes.py |
Checks memory’s Subjects placement. |
dokimi/mkdocs.yml |
Adds the memory guide to navigation. |
dokimi/dokimi/memory/store.py |
Reads memory files and extracts metadata and links. |
dokimi/dokimi/memory/lint.py |
Implements checks and report rendering. |
dokimi/dokimi/memory/__init__.py |
Exposes the memory reader API. |
dokimi/dokimi/commands/memory.py |
Defines the command, options, and exit behavior. |
dokimi/dokimi/cli.py |
Registers the memory command group. |
dokimi/dokimi/cli_routes.py |
Registers memory and its free lint command. |
dokimi/dokimi/__init__.py |
Documents the memory package. |
dokimi/docs/reference/cli-interface.json |
Records the new CLI surface. |
dokimi/docs/README.md |
Links the memory guide. |
dokimi/docs/memory.md |
Documents usage, rules, and exit codes. |
dokimi/docs/architecture.md |
Describes the reader and lint components. |
dokimi/changelog.d/20261113_120000_napetrov_memory-lint.md |
Announces memory lint functionality. |
Review effort: Balanced
| path = posixpath.normpath(target) | ||
| if path.startswith(("../", "/")) or path == "..": | ||
| yield Finding("index-dead-link", "fail", INDEX_NAME, n, f"links {target}, outside the memory directory") | ||
| elif (store.root / path).is_file(): | ||
| linked.add(path) | ||
| else: | ||
| yield Finding("index-dead-link", "fail", INDEX_NAME, n, f"links {target}, which is not a file") |
| INDEX_NAME = "MEMORY.md" | ||
| ARCHIVE_DIR = "archive" | ||
| _FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(?:(.*?)\r?\n)??---[ \t]*(?:\r?\n|\Z)", re.S) | ||
| _MD_LINK = re.compile(r"\]\(\s*(<[^>\n]+>|[^)\s]+)(?:\s+(?:\"[^\"]*\"|'[^']*'))?\s*\)") |
| except OSError as exc: # one unreadable file is a finding, not the end of the run | ||
| return MemoryEntry(rel, "", "", None, f"unreadable: {exc.strerror or exc}", archived) |
|
🔍 OpenCodeReview found 3 issue(s) in this PR. CI at By severity: 3 medium
Review details
|
| @@ -0,0 +1,33 @@ | |||
| # Agent memory | |||
There was a problem hiding this comment.
CI check Static checks (ruff, schema, zizmor, licenses, pre-commit, bandit, secrets, changelog, schema com... fails: The new dokimi/docs/memory.md has no license header, violating .github/.licenserc.yaml. The same job also fails check-added-large-files on cli-interface.json (504 KB); although this diff adds 100 lines to it, the logs do not establish whether the base file was already above 500 KB, so attribution of that additional failure is unknown.
Check run: inconclusive, the result is not shown as a pass.
Suggestion:
| # Agent memory | |
| <!- - | |
| Copyright (C) 2026 Intel Corporation | |
| SPDX-License-Identifier: Apache-2.0 | |
| --> | |
| # Agent memory |
| lines = index.text.split("\n") | ||
| lines = lines[:-1] if lines and not lines[-1] else lines |
There was a problem hiding this comment.
Must-fix. Impact: lint can report no findings and exit successfully despite being unable to read the primary index, including under --fail-on warn. Trigger: a directory containing only a small MEMORY.md with read permission denied to the current user. Evidence: _entry stores the read error in index.frontmatter_error and substitutes empty text, but these index checks never inspect that error; _frontmatter_findings only runs on topic entries. Surface the index read error as a finding and add an unreadable-index test (the existing permission test covers only b.md). Workaround: restore read permission before linting. Severity justification: medium—a bounded false-success verdict hides failure to inspect the session-start input.
| if path.startswith(("../", "/")) or path == "..": | ||
| yield Finding("index-dead-link", "fail", INDEX_NAME, n, f"links {target}, outside the memory directory") | ||
| elif (store.root / path).is_file(): | ||
| linked.add(path) |
There was a problem hiding this comment.
Must-fix. Impact: outside-directory links can be accepted, suppressing the documented index-dead-link failure and allowing a successful verdict. Trigger: [External](external.md) where external.md is a symlink to an existing file outside the store; on Windows, ..\outside.md also bypasses the POSIX-prefix check when the external file exists. Evidence: posixpath.normpath checks only the lexical POSIX path, while Path.is_file() follows symlinks and uses native separators. Check the resolved candidate against the resolved store root before accepting it, with regression tests for symlinks and Windows traversal. Workaround: use ordinary in-directory files and links. Severity justification: medium—a bounded incorrect lint verdict, not demonstrated data exposure or modification.
| INDEX_NAME = "MEMORY.md" | ||
| ARCHIVE_DIR = "archive" | ||
| _FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(?:(.*?)\r?\n)??---[ \t]*(?:\r?\n|\Z)", re.S) | ||
| _MD_LINK = re.compile(r"\]\(\s*(<[^>\n]+>|[^)\s]+)(?:\s+(?:\"[^\"]*\"|'[^']*'))?\s*\)") |
There was a problem hiding this comment.
Must-fix. Impact: a valid index link is reported as index-dead-link, making memory lint exit 1 for an otherwise clean store. Trigger: create a_(draft).md with valid frontmatter and link it as [A](a_(draft).md) in MEMORY.md. Evidence: balanced parentheses are valid in Markdown destinations, but [^)\s]+ stops at the first ), so index_links() returns a_(draft and _link_findings() checks the wrong filename. Workaround: percent-encode the parentheses as a_%28draft%29.md. Parse balanced destination parentheses (or use a Markdown parser) and add a regression test. Severity justification: medium because this is a bounded false failure of the command's core link check for a valid store.
| for m in _MD_LINK.finditer(line): | ||
| target = unquote(m.group(1).strip("<>").split("#", 1)[0]) |
There was a problem hiding this comment.
Must-fix. Impact: valid reference-style index links are ignored, producing false index-orphan warnings for existing entries and missing index-dead-link failures for missing targets. Trigger: MEMORY.md containing [A][topic] followed by [topic]: a.md (a valid Markdown link). Evidence: _MD_LINK only recognizes ](...), so index_links() returns no target for this index; _link_findings() then treats a.md as unlinked, or reports no failure if it is absent. Workaround: rewrite reference links as inline links. Resolve Markdown reference definitions and add tests for both existing and missing destinations. Severity justification: medium because supported Markdown input yields incorrect findings and exit codes, bounded to reference-style links.
| except yaml.YAMLError as exc: | ||
| return MemoryEntry(rel, text, body, None, f"frontmatter is not YAML: {exc.__class__.__name__}", archived) |
There was a problem hiding this comment.
Must-fix. Impact: one malformed timestamp aborts the entire lint run instead of yielding frontmatter-invalid; the CLI treats it as an infrastructure crash (exit 5). Trigger: a topic file with frontmatter modified: 2026-13-01. Evidence: PyYAML's timestamp constructor raises ValueError for invalid calendar values, which this yaml.YAMLError handler does not catch; the command catches only MemoryStoreError and OSError. Catch timestamp-construction errors here and add a regression test that also verifies other files are still checked. Workaround: quote or correct the timestamp. Severity justification: medium—malformed input causes a bounded loss of the core lint operation for the affected store.
Check run: the covering steps pass with this change applied.
Suggestion:
| except yaml.YAMLError as exc: | |
| return MemoryEntry(rel, text, body, None, f"frontmatter is not YAML: {exc.__class__.__name__}", archived) | |
| except (yaml.YAMLError, ValueError) as exc: | |
| return MemoryEntry(rel, text, body, None, f"frontmatter is not YAML: {exc.__class__.__name__}", archived) |
| @@ -0,0 +1,33 @@ | |||
| # Agent memory | |||
There was a problem hiding this comment.
CI check Static checks (ruff, schema, zizmor, licenses, pre-commit, bandit, secrets, changelog, schema com... fails: The newly added dokimi/docs/memory.md has no copyright/SPDX header required by .github/.licenserc.yaml, and the license checker explicitly names it. Separately, pre-commit rejects dokimi/docs/reference/cli-interface.json at 504 KB; its baseline size is unavailable, so attribution of that additional failure is unconfirmed. Proposed fix, not run by the bot.
| # Agent memory | |
| <!- - | |
| Copyright (C) 2026 Intel Corporation | |
| SPDX-License-Identifier: Apache-2.0 | |
| --> | |
| # Agent memory |
The License header check in Static checks fails on the new page without it. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
PyYAML constructors raise ValueError, AttributeError or RecursionError (modified: 2026-02-30, !!timestamp abc, deep nesting); only YAMLError was caught, so one file ended the run with exit 5. Catch any error of the one safe_load call. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
index_links matched every ](...) in MEMORY.md, so a link quoted in code or left in a comment gave a false index-dead-link fail (exit 1) or hid an orphan. Blank them first, keeping line numbers. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
…links [T](topic(v2).md) was read as topic(v2 (a false dead link), and [B][b] with [b]: b.md was not read at all (a false orphan, a missed dead link). Allow one level of balanced parentheses and read each [label]: target line. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
The lexical check accepted esc.md -> ../outside.md and up/ -> .. symlinks, and failed an absolute link to a file inside the store (plus a false orphan). Resolve the target and test containment in the resolved root. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
The read error landed in the index's frontmatter_error, which no rule reads, so a store whose index could not be read gave no findings and exit 0. Raise MemoryStoreError for the index (exit 2); a topic file stays a finding. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
…, code spans Each of these guards survived a mutant: '>' as '>=', the byte near-limit dropped, the #anchor split or mailto exclusion removed (both false dead-link fails), and the code-span skip of the relative-date rule removed. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|
Pushed 8 commits (
On the merged tree: full suite, ruff, mypy, bandit, |
| return [] | ||
| out = [] | ||
| for n, line in enumerate(prose_lines(self.index.text), 1): | ||
| for m in [*_MD_LINK.finditer(line), *_MD_REF_DEF.finditer(line)]: |
There was a problem hiding this comment.
Must-fix. Impact: an otherwise valid index gets an index-dead-link failure and CLI exit 1. Trigger: MEMORY.md contains - [A](a.md) and an unused definition [old]: deleted.md, where a.md exists. Evidence: this loop emits every reference definition as a link without checking whether its label is referenced; Markdown renders no link for the unused definition, yet _link_findings() checks deleted.md. Resolve only definitions used by reference links (including collapsed/shortcut references), and add a regression for an unused missing target. Workaround: remove unused definitions. Severity justification: medium, because this produces a bounded but material false verdict in the core link check.
| out = [] | ||
| for n, line in enumerate(prose_lines(self.index.text), 1): | ||
| for m in [*_MD_LINK.finditer(line), *_MD_REF_DEF.finditer(line)]: | ||
| target = unquote(m.group(1).strip("<>").split("#", 1)[0]) |
There was a problem hiding this comment.
Must-fix. Impact: valid Markdown destinations can still produce index-dead-link, an orphan warning, and exit 1. Trigger: an existing topic(v2).md linked as [Topic](topic\(v2\).md) (valid Markdown escaping). Evidence: the revised regex captures the backslashes and this normalization only URL-decodes them, so the filesystem lookup uses topic\(v2\).md instead of topic(v2).md. The earlier parentheses fix covers unescaped parentheses but not this escaped form. Decode Markdown backslash escapes in destinations and add a regression for escaped parentheses/punctuation. Workaround: use topic%28v2%29.md. Severity justification: medium because a supported, valid link spelling causes a bounded incorrect lint verdict.
There was a problem hiding this comment.
Fixed in 3de7e3f: Backslash escapes in a destination are removed before the lookup, so topic\(v2\).md finds topic(v2).md. tests/test_memory_lint.py::test_escaped_parentheses_and_null_byte_targets pins it and fails without the change.
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #741 +/- ##
==========================================
- Coverage 95.73% 95.67% -0.06%
==========================================
Files 408 419 +11
Lines 54644 55632 +988
Branches 9404 9565 +161
==========================================
+ Hits 52311 53227 +916
- Misses 1442 1499 +57
- Partials 891 906 +15
🚀 New features to boost your workflow:
|
xaleryb
left a comment
There was a problem hiding this comment.
Re-checked at 55725fc (our seven commits plus a merge of main with the regenerated snapshot): a bad frontmatter value is a finding instead of exit 5, links in code and comments are not index links, parenthesised and reference links are read, containment is judged after resolving, an unreadable MEMORY.md exits 2; the full suite, ruff, mypy, bandit, the docs gate, the CLI snapshot step and the large-file hook pass, 15 mutants are killed, and CI is green.
antonwolfy
left a comment
There was a problem hiding this comment.
Two things to fix before merge, and three nits inline. The link containment, the unreadable index, the %20 and parenthesised targets, and the bad-frontmatter-value handling from the earlier bot reviews are fixed at this head. The 25 tests pass, but several lines that decide the default exit code are not pinned, and a valid escaped link still fails the run.
What I ran, on a Linux review host at 55725fc:
make ci-local: 30 passed, 2 failed, 14 skipped. The two failures areInstall gitleaks(the gitleaks download returned HTTP 504) andScan commits, which fails because the binary was never installed. Neither comes from the PR. The tests and the changed-line coverage step pass.- Reproductions for the code findings, with no model or network call.
- A mutation run over
lint.py,store.pyandcommands/memory.py: 101 mutants, 42 of which passtests/test_memory_lint.py. The ones that decide an exit code are in the inline comment on the test file.
Checked, no issues
- Pins: the
--fail-onlogic, exit codes 0/1/2,status: failedin--json, the 200-line edge and the symlink containment all fail the suite when mutated. - Docs: rule names, severities, flags and exit codes in
docs/memory.mdmatch the code and the CLI snapshot. The nav entry, the licence header and the changelog fragment are present. - Commits: all ten non-merge commits carry
Signed-off-by. - Tests: they write only under
tmp_pathand make no network call.
Bot points I checked that no longer apply at this head (9)
- Copilot's point on symlinks and
..escaping the memory directory does not apply:_link_findingsresolves the target and rejects it unless it lands under the resolved root, andtest_containment_is_judged_after_resolvingcovers a symlink, a symlinked directory, an absolute path and a loop. - Copilot's point on
topic(v2).mddoes not apply:_MD_LINKaccepts one level of balanced parentheses, and[T](topic(v2).md)gives no finding. - Copilot's point on an unreadable
MEMORY.mddoes not apply:_entryraisesMemoryStoreErrorfor the index, so the command exits 2, andtest_unreadable_index_is_an_input_errorpins it. - exai-bot's point on the missing licence header in
docs/memory.mddoes not apply: the header is at the top of the file, and the static checks pass. - exai-bot's point on an unreadable index reporting no findings does not apply: the index read error now raises, and the command exits 2.
- exai-bot's point on a symlink target outside the store does not apply: containment is judged on the resolved path.
- exai-bot's point on
a_(draft).mddoes not apply: the link regex accepts balanced parentheses. - exai-bot's point on reference-style links does not apply:
[A][t]with[t]: a.mdgives no finding, and a missing target is reported. - exai-bot's point on
modified: 2026-13-01does not apply: any exception from the YAML load becomes afrontmatter-invalidfinding and the other files are still checked.
| (mem / "MEMORY.md").write_text(f"- [A]({mem / 'a.md'})\n- [E](esc.md)\n- [U](up/outside.md)\n" | ||
| f"- [O]({tmp_path / 'outside.md'})\n- [L](loop1.md)\n") | ||
| found = [(f.rule, f.file, f.line) for f in lint(read_claude_memory(mem)) if f.rule.startswith("index")] | ||
| assert found == [("index-dead-link", "MEMORY.md", n) for n in (2, 3, 4, 5)] + [("index-orphan", "esc.md", None)] |
There was a problem hiding this comment.
Lines that decide the default exit code are not pinned. Three mutants of the new code pass all 25 tests:
lint.py:88: the outside-the-directory finding changed from"fail"to"warn". The test checks the rule name and line, not the severity, so a symlink escape would stop failing the default gate.store.py:92:"://" not in targetremoved. No test has anhttps://link, so[docs](https://example.com)would become afaildead link and exit 1 on a healthy store.lint.py:57:size > max_byteschanged to>=. Only the 200-line edge is pinned; the 25 KiB byte edge is not (25600 and 25601 bytes).
Mutation check: each of the three passes the full file (25 passed).
Suggestion: assert the severity here (("index-dead-link", "fail", "MEMORY.md", n)), add an https:// link to the clean-store test, and add a byte-edge test with the default limits.
There was a problem hiding this comment.
Fixed in 3de7e3f: Added a case for each mutant: the outside link is fail, an https:// link is skipped, and max_bytes equal to the size passes while one byte less fails. tests/test_memory_lint.py::test_outside_link_fails_urls_are_skipped_and_byte_limit_edge pins it and fails without the change.
| out = [] | ||
| for n, line in enumerate(prose_lines(self.index.text), 1): | ||
| for m in [*_MD_LINK.finditer(line), *_MD_REF_DEF.finditer(line)]: | ||
| target = unquote(m.group(1).strip("<>").split("#", 1)[0]) |
There was a problem hiding this comment.
I agree with exai-bot here; a backslash-escaped destination still gives a false failure. For an existing topic(v2).md, an index line - [T](topic\(v2\).md) reads as the target topic\(v2\).md, since unquote only decodes %xx. The run gives index-dead-link fail at line 1 and index-orphan warn for topic(v2).md, so memory lint exits 1 on a correct index. The unescaped form topic(v2).md passes.
Suggestion: after the unquote, remove a backslash that precedes an ASCII punctuation character (CommonMark backslash escapes), and add the escaped form to test_parenthesised_targets_and_reference_definitions_are_links.
There was a problem hiding this comment.
Fixed in 3de7e3f: Backslash escapes in a destination are removed before the lookup, so topic\(v2\).md finds topic(v2).md. tests/test_memory_lint.py::test_escaped_parentheses_and_null_byte_targets pins it and fails without the change.
| return [] | ||
| out = [] | ||
| for n, line in enumerate(prose_lines(self.index.text), 1): | ||
| for m in [*_MD_LINK.finditer(line), *_MD_REF_DEF.finditer(line)]: |
There was a problem hiding this comment.
Nit: I agree with exai-bot here; a reference definition nothing refers to is checked as a link. An index of - [A](a.md) followed by [old]: deleted.md, with a.md present, gives index-dead-link fail at line 3, and the run exits 1. A renderer draws no link for an unused definition.
Nit: this is defensible as a stale-entry check, but the docs do not say so.
Suggestion: either keep only definitions whose label appears as ][label] or [label] elsewhere in the prose, or add a line to the index-dead-link row of docs/memory.md saying that unused reference definitions are checked.
| for n, target in store.index_links(): | ||
| try: # resolved, so a symlink or an absolute path is judged by where it lands | ||
| full = (store.root / target).resolve() | ||
| except (OSError, RuntimeError): |
There was a problem hiding this comment.
Nit: A %00 in an index link crashes the run. [t](x%00.md) is decoded to x\x00.md, and Path.resolve() raises ValueError: embedded null character in path. The except here catches OSError and RuntimeError only, so the command ends with a traceback instead of a finding.
Suggestion: except (OSError, RuntimeError, ValueError):.
There was a problem hiding this comment.
Fixed in 3de7e3f: ValueError from resolve() is caught, so [t](x%00.md) is an index-dead-link. tests/test_memory_lint.py::test_escaped_parentheses_and_null_byte_targets pins it and fails without the change.
| for n, line in enumerate(entry.body.split("\n"), offset + 1): | ||
| if _DATE.search(line): # a dated line says when "today" was | ||
| continue | ||
| line = _CODE.sub(lambda c: " " * len(c.group(0)), line) |
There was a problem hiding this comment.
Nit: relative-date fires inside fenced code blocks. A topic body with a fence holding run it tomorrow gives relative-date warn r.md 7 'tomorrow' without a date, and --fail-on warn then exits 1. docs/memory.md says only code spans are skipped.
Suggestion: scan prose_lines(entry.body) from store.py instead of entry.body.split("\n") so fences and HTML comments are blanked as they are for index links.
There was a problem hiding this comment.
Fixed in 3de7e3f: relative-date scans prose_lines(entry.body), so fenced blocks and HTML comments are skipped; docs/memory.md says so. tests/test_memory_lint.py::test_relative_date_skips_fences_and_comments pins it and fails without the change.
- `topic\(v2\).md` in an index link is unescaped before the lookup - `[t](x%00.md)` is an index-dead-link, not a ValueError traceback - a code-span delimiter run closes on a run of the same length, and code spans are blanked before HTML comments open - fences are read before comments, so `<!--` inside a fence opens none - relative-date skips fenced blocks and HTML comments, as links do - tests pin the outside-link severity, URL targets and the 25 KiB edge Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com>
|
Pushed 1 commit to
Threads: 5 fixed, 0 answered as not a defect. The PR description needs these corrections:
Questions for @napetrov1. Should an unused reference definition such as
2. Should an unused reference definition such as
|
| INDEX_NAME = "MEMORY.md" | ||
| ARCHIVE_DIR = "archive" | ||
| _FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(?:(.*?)\r?\n)??---[ \t]*(?:\r?\n|\Z)", re.S) | ||
| _MD_LINK = re.compile(r"\]\(\s*(<[^>\n]+>|(?:[^()\s]|\([^()\s]*\))+)(?:\s+(?:\"[^\"]*\"|'[^']*'))?\s*\)") |
There was a problem hiding this comment.
Must-fix. Impact: literal Markdown examples can produce index-dead-link failures and default CLI exit 1. Trigger: MEMORY.md contains \[Example](missing.md) alongside valid topic links. Evidence: _MD_LINK matches only the ](...) suffix, so it extracts missing.md even though the escaped opening bracket means Markdown renders literal text, not a link. Require an actual unescaped link opener (accounting for backslash parity), and add a regression for this case. Workaround: wrap literal examples in code spans. Severity justification: medium—a bounded false verdict in the command's core link check.
| out = [] | ||
| for n, line in enumerate(prose_lines(self.index.text), 1): | ||
| for m in [*_MD_LINK.finditer(line), *_MD_REF_DEF.finditer(line)]: | ||
| target = unquote(_ESCAPE.sub(r"\1", m.group(1).strip("<>")).split("#", 1)[0]) |
There was a problem hiding this comment.
Must-fix. Impact: a valid Markdown index can produce index-dead-link, an orphan warning, and default CLI exit 1. Trigger: create a topic named a&b.md and link it as [Topic](a&b.md) in MEMORY.md. Evidence: Markdown resolves the character reference to a&b.md, but this normalization only handles backslash escapes and percent encoding, so _link_findings() looks for the nonexistent literal a&b.md. Decode Markdown character references in destinations and add a regression covering both inline links and reference definitions. Workaround: write the destination as a%26b.md. Severity justification: medium—a supported Markdown spelling causes a bounded incorrect verdict in the core link check.
| for n, line in enumerate(prose_lines(self.index.text), 1): | ||
| for m in [*_MD_LINK.finditer(line), *_MD_REF_DEF.finditer(line)]: | ||
| target = unquote(_ESCAPE.sub(r"\1", m.group(1).strip("<>")).split("#", 1)[0]) | ||
| if target and "://" not in target and not target.startswith("mailto:"): |
There was a problem hiding this comment.
Must-fix. Impact: valid external URI links are treated as missing local files, producing index-dead-link and default CLI exit 1. Trigger: add [Call](tel:+123456789) or [Email](MAILTO:x@example.com) to an otherwise clean index. Evidence: neither target contains :// nor starts with lowercase mailto:, so both reach the filesystem check despite index_links() promising to exclude URLs. Recognize URI schemes case-insensitively, while preserving supported native filesystem paths, and add regressions. Workaround: remove these links or render them as plain text. Severity justification: medium—a bounded incorrect failure for valid index content.

Summary
First leaf of the
memoryroot (#728):dokimi memory lint <dir>reads a Claude Code auto-memorydirectory (
MEMORY.mdindex, topic files with YAML frontmatter,archive/) and reports deterministicfindings. It makes no model call and needs no network.
index-dead-link(missing file, directory, or a path outside the dir) ·index-over-limit(200 lines / 25KB on disk)index-orphan·index-near-limit·index-missing·frontmatter-missing/invalid/field·type-unknown·name-duplicate·relative-datewikilink-dangling·index-stray-separatorExit codes:
--fail-on warnalso fails on a warn);--jsonprintscli_output.v1.New package
dokimi/memory/(tracks layer), commanddokimi/commands/memory.py, guidedocs/memory.md.Evidence
:, [separators and undated "today" lines by hand. This leaf now finds all of them.frontmatter-invalidfindings, all unquoted:inside adescription. Claude Code reads that frontmatter as broken.tests/test_memory_lint.py: 16 offline tests covering every rule, the link forms (title,<>,%20,../, directories), CRLF size, BOM, the archive, aliases, date false positives, exit codes and JSON.make ci-localis green except the pre-commit large-file hook.docs/reference/cli-interface.jsonwas already over 500 KB on main; fix(cli): the CLI snapshot writes one line per parameter #737, cli-interface.json: dump with indent=1 to stay under the 500 KB limit #738 and ci: raise the large-file limit to 1024 KB (main's Static checks is red) #740 fix that.%20, the greedy empty-frontmatter match.Merge Danger
Low. The change adds a new root and leaf,
memory/memory lint, both free. No existing command changes. Thememory lintkind and its--fail-onflag become part of the CLI surface.Next:
memory usage(reads, writes and injections per file from session logs), thenmemory ground.🤖 Generated with Claude Code