Skip to content
Open
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ established wikis and copy only the useful proposal parts into `schema.md`.
```yaml
---
title: "Title"
source: "URL or filepath or MANUAL"
source: "URL, filepath, MANUAL, or provenance label such as session"
type: articles|papers|repos|notes|data
ingested: YYYY-MM-DD
tags: [tag1, tag2]
Expand Down Expand Up @@ -854,7 +854,7 @@ Health checks with auto-fix capability. Lint **is** the migration path — there
path/status drift. Normal lint reports archived topics as skipped;
`--include-archived` or `--archived-only` structurally maintains them.

**Checks**: structure integrity, frontmatter validity (plus legacy key/value aliases C13), canonical placement of raw/wiki files (C11), unknown-file quarantine for raw/wiki/inventory/datasets/root (C12), index consistency, link integrity, source provenance (dangling refs, unresolved retraction markers), tag hygiene, coverage, project `WHY.md` presence (C8a), project staleness via source chain (C8b), legacy `_project.md` migration to `WHY.md` (C8c), project candidates (C9), inventory migration candidates (C16), dataset migration candidates (C17), archive registry drift and active/archive collisions (C19), deep fact-checking (optional).
**Checks**: structure integrity, frontmatter validity (plus legacy key/value aliases C13), canonical placement of raw/wiki files (C11), unknown-file quarantine for raw/wiki/inventory/datasets/root (C12), index consistency, link integrity, source provenance (dangling refs, unresolved explicit local raw-source paths, unresolved retraction markers), tag hygiene, coverage, project `WHY.md` presence (C8a), project staleness via source chain (C8b), legacy `_project.md` migration to `WHY.md` (C8c), project candidates (C9), inventory migration candidates (C16), dataset migration candidates (C17), archive registry drift and active/archive collisions (C19), deep fact-checking (optional).

**Auto-fix** (`--fix`): rewrite legacy frontmatter keys/values to canonical (C13), move misplaced raw/wiki files to their canonical directory (C11), quarantine unknown files to `inbox/.unknown` (C12), migrate legacy `_project.md` to `WHY.md` (C8c), add a default human-owned `schema.md` in advisory mode when missing, repair missing indexes inside existing inventory/dataset layers (C16/C17), repair unambiguous archive registry path/status drift (C19), missing indexes, orphan files, dead index entries, statistics mismatch, missing bidirectional links, empty frontmatter fields, dangling source references, regenerate projects-aware `output/_index.md`. Never auto-delete unknown directories. Never auto-create `WHY.md` with placeholder goals (C8a is warn-only — manufactured rationale is worse than missing). Never create completely absent optional inventory or dataset trees just to populate placeholders. Never auto-move files into projects (C9 is human-authored via `/wiki:project`). Never auto-migrate output artifacts into inventory or dataset records (C16/C17 are explicit via `/wiki:inventory migrate-output --apply` and `/wiki:dataset migrate-output --apply`). Never move topics into or out of archive during lint; archive/restore is explicit. On slug collisions during a placement move, skip and warn.

Expand Down
77 changes: 77 additions & 0 deletions claude-plugin/bin/llm-wiki
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ CONFIDENCE_VALUES = {"high", "medium", "low"}
VOLATILITY_VALUES = {"hot", "warm", "cold"}
PERMISSION_DENIED_ERRNOS = {1, 13}
WINDOWS_ABSOLUTE_PATH_RE = re.compile(r"^/?[A-Za-z]:[\\/]")
SCHEMELESS_WEB_SOURCE_RE = re.compile(
r"^(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?::\d+)?(?:[/#?]|$)"
)
ADAPTER_PROTOCOL = "llm-wiki-adapter/v1"
ADAPTER_REGISTRY_SCHEMA = 1
ADAPTER_MANIFEST_NAME = ".llm-wiki-adapter.json"
Expand Down Expand Up @@ -1473,6 +1476,16 @@ def check_source_provenance(ctx: LintContext) -> None:
f"Inventory source reference does not resolve: {source}.",
doc.path,
)
elif rel.parts[0] == "raw":
source = doc.frontmatter.get("source")
if source:
candidate = raw_source_path_candidate(ctx, doc.path, str(source))
if candidate is not None and not source_path_exists(candidate):
ctx.issue(
"warning",
f"Raw source reference does not resolve: {source}.",
doc.path,
)

if "RETRACTED-SOURCE" in doc.body:
ctx.issue("warning", "Retracted-source marker remains in the file body.", doc.path)
Expand Down Expand Up @@ -1562,6 +1575,70 @@ def resolve_source_ref(ctx: LintContext, owner: Path, ref: str, wiki_source: boo
return None


def raw_source_path_candidate(ctx: LintContext, owner: Path, ref: str) -> Path | None:
"""Return a candidate only when a raw source value is clearly a local path.

Raw provenance also uses sentinels such as ``session`` and descriptive
labels. Treating every non-HTTP value as a path would make those established
values fail lint. URI schemes remain external except for ``file://``; bare
relative paths must contain a separator and no whitespace to be considered
unambiguous.
"""
ref = strip_matching_quotes(ref.strip())
if not ref:
return None

windows_ref = windows_absolute_path(ref)
if windows_ref is not None:
return windows_ref

try:
parsed = urllib.parse.urlsplit(ref)
except ValueError:
parsed = None
if parsed is not None and parsed.scheme:
# Do not mistake a Windows drive letter for a URI scheme on a platform
# where Windows paths are not native.
if WINDOWS_ABSOLUTE_PATH_RE.match(ref):
return Path(ref)
if parsed.scheme.lower() != "file":
return None
path_text = urllib.parse.unquote(parsed.path)
if parsed.netloc and parsed.netloc.lower() != "localhost":
path_text = f"//{parsed.netloc}{path_text}"
if os.name == "nt" and re.match(r"^/[A-Za-z]:/", path_text):
path_text = path_text[1:]
file_path = Path(path_text)
return file_path if file_path.is_absolute() else ctx.root / file_path

if SCHEMELESS_WEB_SOURCE_RE.match(ref):
return None

explicit_relative = ref.startswith(("../", "./", "..\\", ".\\"))
home_relative = ref == "~" or ref.startswith(("~/", "~\\"))
absolute = Path(ref).is_absolute()
unambiguous_bare_relative = (
not any(char.isspace() for char in ref) and ("/" in ref or "\\" in ref)
)
if not (explicit_relative or home_relative or absolute or unambiguous_bare_relative):
return None

ref_path = expand_leading_tilde(ref)
if ref_path.is_absolute():
return ref_path
if explicit_relative:
return owner.parent / ref_path
return ctx.root / ref_path


def source_path_exists(path: Path) -> bool:
"""Check a source file or directory without crashing on sandbox denials."""
try:
return path.exists()
except OSError:
return False


def strip_matching_quotes(value: str) -> str:
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
Expand Down
8 changes: 8 additions & 0 deletions claude-plugin/skills/wiki-manager/references/linting.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ There is no `/wiki:migrate` command and there should never be one. Lint rules **

- [ ] All markdown links `[text](path)` in wiki articles and inventory records
resolve to existing local files when they are local paths
- [ ] Do not apply this check to raw source bodies. Imported Markdown may retain
relative links whose targets live only in the upstream source collection.
- [ ] All "See Also" links are bidirectional (if A→B, then B→A)
- [ ] All "Sources" links in wiki articles point to existing raw files. Links to paths with spaces should use angle-bracket markdown destinations, e.g. `[Title](<../../raw/articles/File Name.md>)`.

Expand All @@ -112,6 +114,12 @@ There is no `/wiki:migrate` command and there should never be one. Lint rules **
existing files under `raw/`, `wiki/`, `output/`, `datasets/`, or `inventory/`.
External URLs are allowed. Inventory provenance is operational state and must
not be treated as factual evidence for compile/query/audit verdicts.
- [ ] Explicit local paths in a raw source's scalar `source:` field resolve to
an existing file or directory. Treat absolute paths, `file://` URIs,
`./`/`../`/`~/` paths, and whitespace-free relative paths containing a
directory separator as local. Other URI schemes, schemeless web URLs,
sentinels such as `MANUAL` or `session`, and free-form provenance labels are
not local-path checks.
- [ ] No `<!--RETRACTED-SOURCE-->` markers remain in article body (these should be resolved via `--recompile` or manual review)
- [ ] No raw source file is referenced by zero wiki articles (orphan source — suggest compilation or removal)
- [ ] Exempt raw files tagged `collection-manifest` from orphan-source warnings. A collection manifest is operational provenance for a batch import; child sources should be compiled, but the manifest itself does not need to appear in article `sources:`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ Topic-guide helpers:
```markdown
---
title: "Title"
source: "URL or filepath or MANUAL"
source: "URL, filepath, MANUAL, or provenance label such as session"
type: articles|papers|repos|notes|data
ingested: YYYY-MM-DD
tags: [tag1, tag2]
Expand Down
77 changes: 77 additions & 0 deletions plugins/llm-wiki-opencode/bin/llm-wiki
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ CONFIDENCE_VALUES = {"high", "medium", "low"}
VOLATILITY_VALUES = {"hot", "warm", "cold"}
PERMISSION_DENIED_ERRNOS = {1, 13}
WINDOWS_ABSOLUTE_PATH_RE = re.compile(r"^/?[A-Za-z]:[\\/]")
SCHEMELESS_WEB_SOURCE_RE = re.compile(
r"^(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?::\d+)?(?:[/#?]|$)"
)
ADAPTER_PROTOCOL = "llm-wiki-adapter/v1"
ADAPTER_REGISTRY_SCHEMA = 1
ADAPTER_MANIFEST_NAME = ".llm-wiki-adapter.json"
Expand Down Expand Up @@ -1473,6 +1476,16 @@ def check_source_provenance(ctx: LintContext) -> None:
f"Inventory source reference does not resolve: {source}.",
doc.path,
)
elif rel.parts[0] == "raw":
source = doc.frontmatter.get("source")
if source:
candidate = raw_source_path_candidate(ctx, doc.path, str(source))
if candidate is not None and not source_path_exists(candidate):
ctx.issue(
"warning",
f"Raw source reference does not resolve: {source}.",
doc.path,
)

if "RETRACTED-SOURCE" in doc.body:
ctx.issue("warning", "Retracted-source marker remains in the file body.", doc.path)
Expand Down Expand Up @@ -1562,6 +1575,70 @@ def resolve_source_ref(ctx: LintContext, owner: Path, ref: str, wiki_source: boo
return None


def raw_source_path_candidate(ctx: LintContext, owner: Path, ref: str) -> Path | None:
"""Return a candidate only when a raw source value is clearly a local path.

Raw provenance also uses sentinels such as ``session`` and descriptive
labels. Treating every non-HTTP value as a path would make those established
values fail lint. URI schemes remain external except for ``file://``; bare
relative paths must contain a separator and no whitespace to be considered
unambiguous.
"""
ref = strip_matching_quotes(ref.strip())
if not ref:
return None

windows_ref = windows_absolute_path(ref)
if windows_ref is not None:
return windows_ref

try:
parsed = urllib.parse.urlsplit(ref)
except ValueError:
parsed = None
if parsed is not None and parsed.scheme:
# Do not mistake a Windows drive letter for a URI scheme on a platform
# where Windows paths are not native.
if WINDOWS_ABSOLUTE_PATH_RE.match(ref):
return Path(ref)
if parsed.scheme.lower() != "file":
return None
path_text = urllib.parse.unquote(parsed.path)
if parsed.netloc and parsed.netloc.lower() != "localhost":
path_text = f"//{parsed.netloc}{path_text}"
if os.name == "nt" and re.match(r"^/[A-Za-z]:/", path_text):
path_text = path_text[1:]
file_path = Path(path_text)
return file_path if file_path.is_absolute() else ctx.root / file_path

if SCHEMELESS_WEB_SOURCE_RE.match(ref):
return None

explicit_relative = ref.startswith(("../", "./", "..\\", ".\\"))
home_relative = ref == "~" or ref.startswith(("~/", "~\\"))
absolute = Path(ref).is_absolute()
unambiguous_bare_relative = (
not any(char.isspace() for char in ref) and ("/" in ref or "\\" in ref)
)
if not (explicit_relative or home_relative or absolute or unambiguous_bare_relative):
return None

ref_path = expand_leading_tilde(ref)
if ref_path.is_absolute():
return ref_path
if explicit_relative:
return owner.parent / ref_path
return ctx.root / ref_path


def source_path_exists(path: Path) -> bool:
"""Check a source file or directory without crashing on sandbox denials."""
try:
return path.exists()
except OSError:
return False


def strip_matching_quotes(value: str) -> str:
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
Expand Down
77 changes: 77 additions & 0 deletions plugins/llm-wiki/bin/llm-wiki
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ CONFIDENCE_VALUES = {"high", "medium", "low"}
VOLATILITY_VALUES = {"hot", "warm", "cold"}
PERMISSION_DENIED_ERRNOS = {1, 13}
WINDOWS_ABSOLUTE_PATH_RE = re.compile(r"^/?[A-Za-z]:[\\/]")
SCHEMELESS_WEB_SOURCE_RE = re.compile(
r"^(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?::\d+)?(?:[/#?]|$)"
)
ADAPTER_PROTOCOL = "llm-wiki-adapter/v1"
ADAPTER_REGISTRY_SCHEMA = 1
ADAPTER_MANIFEST_NAME = ".llm-wiki-adapter.json"
Expand Down Expand Up @@ -1473,6 +1476,16 @@ def check_source_provenance(ctx: LintContext) -> None:
f"Inventory source reference does not resolve: {source}.",
doc.path,
)
elif rel.parts[0] == "raw":
source = doc.frontmatter.get("source")
if source:
candidate = raw_source_path_candidate(ctx, doc.path, str(source))
if candidate is not None and not source_path_exists(candidate):
ctx.issue(
"warning",
f"Raw source reference does not resolve: {source}.",
doc.path,
)

if "RETRACTED-SOURCE" in doc.body:
ctx.issue("warning", "Retracted-source marker remains in the file body.", doc.path)
Expand Down Expand Up @@ -1562,6 +1575,70 @@ def resolve_source_ref(ctx: LintContext, owner: Path, ref: str, wiki_source: boo
return None


def raw_source_path_candidate(ctx: LintContext, owner: Path, ref: str) -> Path | None:
"""Return a candidate only when a raw source value is clearly a local path.

Raw provenance also uses sentinels such as ``session`` and descriptive
labels. Treating every non-HTTP value as a path would make those established
values fail lint. URI schemes remain external except for ``file://``; bare
relative paths must contain a separator and no whitespace to be considered
unambiguous.
"""
ref = strip_matching_quotes(ref.strip())
if not ref:
return None

windows_ref = windows_absolute_path(ref)
if windows_ref is not None:
return windows_ref

try:
parsed = urllib.parse.urlsplit(ref)
except ValueError:
parsed = None
if parsed is not None and parsed.scheme:
# Do not mistake a Windows drive letter for a URI scheme on a platform
# where Windows paths are not native.
if WINDOWS_ABSOLUTE_PATH_RE.match(ref):
return Path(ref)
if parsed.scheme.lower() != "file":
return None
path_text = urllib.parse.unquote(parsed.path)
if parsed.netloc and parsed.netloc.lower() != "localhost":
path_text = f"//{parsed.netloc}{path_text}"
if os.name == "nt" and re.match(r"^/[A-Za-z]:/", path_text):
path_text = path_text[1:]
file_path = Path(path_text)
return file_path if file_path.is_absolute() else ctx.root / file_path

if SCHEMELESS_WEB_SOURCE_RE.match(ref):
return None

explicit_relative = ref.startswith(("../", "./", "..\\", ".\\"))
home_relative = ref == "~" or ref.startswith(("~/", "~\\"))
absolute = Path(ref).is_absolute()
unambiguous_bare_relative = (
not any(char.isspace() for char in ref) and ("/" in ref or "\\" in ref)
)
if not (explicit_relative or home_relative or absolute or unambiguous_bare_relative):
return None

ref_path = expand_leading_tilde(ref)
if ref_path.is_absolute():
return ref_path
if explicit_relative:
return owner.parent / ref_path
return ctx.root / ref_path


def source_path_exists(path: Path) -> bool:
"""Check a source file or directory without crashing on sandbox denials."""
try:
return path.exists()
except OSError:
return False


def strip_matching_quotes(value: str) -> str:
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
Expand Down
8 changes: 8 additions & 0 deletions plugins/llm-wiki/skills/wiki/references/linting.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ There is no `/wiki:migrate` command and there should never be one. Lint rules **

- [ ] All markdown links `[text](path)` in wiki articles and inventory records
resolve to existing local files when they are local paths
- [ ] Do not apply this check to raw source bodies. Imported Markdown may retain
relative links whose targets live only in the upstream source collection.
- [ ] All "See Also" links are bidirectional (if A→B, then B→A)
- [ ] All "Sources" links in wiki articles point to existing raw files. Links to paths with spaces should use angle-bracket markdown destinations, e.g. `[Title](<../../raw/articles/File Name.md>)`.

Expand All @@ -112,6 +114,12 @@ There is no `/wiki:migrate` command and there should never be one. Lint rules **
existing files under `raw/`, `wiki/`, `output/`, `datasets/`, or `inventory/`.
External URLs are allowed. Inventory provenance is operational state and must
not be treated as factual evidence for compile/query/audit verdicts.
- [ ] Explicit local paths in a raw source's scalar `source:` field resolve to
an existing file or directory. Treat absolute paths, `file://` URIs,
`./`/`../`/`~/` paths, and whitespace-free relative paths containing a
directory separator as local. Other URI schemes, schemeless web URLs,
sentinels such as `MANUAL` or `session`, and free-form provenance labels are
not local-path checks.
- [ ] No `<!--RETRACTED-SOURCE-->` markers remain in article body (these should be resolved via `--recompile` or manual review)
- [ ] No raw source file is referenced by zero wiki articles (orphan source — suggest compilation or removal)
- [ ] Exempt raw files tagged `collection-manifest` from orphan-source warnings. A collection manifest is operational provenance for a batch import; child sources should be compiled, but the manifest itself does not need to appear in article `sources:`.
Expand Down
2 changes: 1 addition & 1 deletion plugins/llm-wiki/skills/wiki/references/wiki-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ Topic-guide helpers:
```markdown
---
title: "Title"
source: "URL or filepath or MANUAL"
source: "URL, filepath, MANUAL, or provenance label such as session"
type: articles|papers|repos|notes|data
ingested: YYYY-MM-DD
tags: [tag1, tag2]
Expand Down
Loading