logmind 0.1.0__py3-none-any.whl
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.
- logmind/__init__.py +8 -0
- logmind/actions/__init__.py +5 -0
- logmind/actions/aggregate.py +108 -0
- logmind/actions/link_check.py +193 -0
- logmind/cli.py +1224 -0
- logmind/core/__init__.py +1 -0
- logmind/core/aggregator.py +78 -0
- logmind/core/analytics.py +97 -0
- logmind/core/config.py +233 -0
- logmind/core/decision_templates.py +86 -0
- logmind/core/git_handler.py +277 -0
- logmind/core/gitignore.py +60 -0
- logmind/core/inserter.py +623 -0
- logmind/core/logger.py +323 -0
- logmind/core/parser.py +22 -0
- logmind/core/search.py +192 -0
- logmind/core/skill_install.py +68 -0
- logmind/core/tree_gen.py +208 -0
- logmind/decorators.py +181 -0
- logmind/integrations/__init__.py +5 -0
- logmind/integrations/base.py +43 -0
- logmind/integrations/langchain.py +79 -0
- logmind/templates/AGENTS.md.template +81 -0
- logmind/templates/CLAUDE.md.template +53 -0
- logmind/templates/agent-stub.md +3 -0
- logmind/templates/config.yml.template +61 -0
- logmind/templates/decisions-archive.md.template +7 -0
- logmind/templates/decisions.md.template +5 -0
- logmind/templates/file-structure.md.template +9 -0
- logmind/templates/github/check-doc-links.yml.template +31 -0
- logmind/templates/github/logmind-aggregate.yml.template +49 -0
- logmind/templates/logmind-section.md +68 -0
- logmind-0.1.0.dist-info/METADATA +219 -0
- logmind-0.1.0.dist-info/RECORD +38 -0
- logmind-0.1.0.dist-info/WHEEL +5 -0
- logmind-0.1.0.dist-info/entry_points.txt +2 -0
- logmind-0.1.0.dist-info/licenses/LICENSE +21 -0
- logmind-0.1.0.dist-info/top_level.txt +1 -0
logmind/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Aggregate per-branch decision logs into docs/decisions.md on PR merge.
|
|
2
|
+
|
|
3
|
+
Designed to be invoked by ``.github/workflows/logmind-aggregate.yml`` after a
|
|
4
|
+
pull request is merged. Reads the per-branch decisions file written by Phase 5
|
|
5
|
+
and appends a single summary entry to the canonical decisions log so the
|
|
6
|
+
default branch's history shows merged work alongside its detail link.
|
|
7
|
+
|
|
8
|
+
Importable for unit testing; ``main()`` adapts environment variables for use
|
|
9
|
+
with ``python -m logmind.actions.aggregate``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from logmind.core.logger import _sanitize_branch
|
|
21
|
+
from logmind.core.parser import iter_decisions
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def aggregate(
|
|
25
|
+
branch: str,
|
|
26
|
+
pr_number: int,
|
|
27
|
+
pr_url: str,
|
|
28
|
+
docs_path: Path,
|
|
29
|
+
timestamp: Optional[datetime] = None,
|
|
30
|
+
) -> Optional[Path]:
|
|
31
|
+
"""
|
|
32
|
+
Append a merge-summary entry to ``docs/decisions.md``.
|
|
33
|
+
|
|
34
|
+
Returns the path to the updated file, or ``None`` if the per-branch file is
|
|
35
|
+
missing / empty (in which case there is nothing to summarise and the main
|
|
36
|
+
log is left untouched).
|
|
37
|
+
"""
|
|
38
|
+
if timestamp is None:
|
|
39
|
+
timestamp = datetime.now()
|
|
40
|
+
|
|
41
|
+
sanitized = _sanitize_branch(branch)
|
|
42
|
+
branch_file = docs_path / "decisions-branches" / f"{sanitized}.md"
|
|
43
|
+
if not branch_file.exists():
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
decision_count = sum(1 for _ in iter_decisions(branch_file))
|
|
47
|
+
if decision_count == 0:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
rel_link = f"decisions-branches/{sanitized}.md"
|
|
51
|
+
entry = (
|
|
52
|
+
f"## {timestamp.strftime('%Y-%m-%d %H:%M')} - "
|
|
53
|
+
f"Merged: {branch} (#{pr_number})\n"
|
|
54
|
+
"\n"
|
|
55
|
+
f"- **PR:** {pr_url}\n"
|
|
56
|
+
f"- **Decisions:** {decision_count} from this branch\n"
|
|
57
|
+
f"- **Detail:** [{rel_link}]({rel_link})\n"
|
|
58
|
+
"\n"
|
|
59
|
+
"---\n"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
decisions_path = docs_path / "decisions.md"
|
|
63
|
+
existing = decisions_path.read_text(encoding="utf-8") if decisions_path.exists() else ""
|
|
64
|
+
decisions_path.write_text(existing + entry, encoding="utf-8")
|
|
65
|
+
return decisions_path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main() -> int:
|
|
69
|
+
"""Entry point for ``python -m logmind.actions.aggregate``."""
|
|
70
|
+
branch = os.environ.get("BRANCH_NAME") or os.environ.get("GITHUB_HEAD_REF")
|
|
71
|
+
pr_number_str = os.environ.get("PR_NUMBER")
|
|
72
|
+
pr_url = os.environ.get("PR_URL")
|
|
73
|
+
|
|
74
|
+
if not branch or not pr_number_str or not pr_url:
|
|
75
|
+
print(
|
|
76
|
+
"logmind aggregate: BRANCH_NAME, PR_NUMBER, and PR_URL must be set",
|
|
77
|
+
file=sys.stderr,
|
|
78
|
+
)
|
|
79
|
+
return 2
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
pr_number = int(pr_number_str)
|
|
83
|
+
except ValueError:
|
|
84
|
+
print(f"logmind aggregate: PR_NUMBER must be an integer, got {pr_number_str!r}",
|
|
85
|
+
file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
|
|
88
|
+
docs_path = Path(os.environ.get("LOGMIND_DOCS", "docs"))
|
|
89
|
+
if not docs_path.is_absolute():
|
|
90
|
+
docs_path = Path.cwd() / docs_path
|
|
91
|
+
|
|
92
|
+
result = aggregate(
|
|
93
|
+
branch=branch,
|
|
94
|
+
pr_number=pr_number,
|
|
95
|
+
pr_url=pr_url,
|
|
96
|
+
docs_path=docs_path,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if result is None:
|
|
100
|
+
print(f"logmind aggregate: no per-branch decisions for {branch}; nothing to do.")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
print(f"logmind aggregate: appended merge summary for {branch} to {result}")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__": # pragma: no cover
|
|
108
|
+
sys.exit(main())
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Markdown link integrity checker.
|
|
2
|
+
|
|
3
|
+
Walks a configurable set of root files/directories, parses every relative
|
|
4
|
+
``[text](target)`` markdown link, and reports two failure modes:
|
|
5
|
+
|
|
6
|
+
- **Broken**: the link target does not exist on disk.
|
|
7
|
+
- **Orphan**: a tracked ``.md`` file under ``docs/`` that no other tracked
|
|
8
|
+
file links to AND is not on an allowlist.
|
|
9
|
+
|
|
10
|
+
Importable for unit testing; ``main()`` is the entry point for the
|
|
11
|
+
``logmind check-links`` CLI subcommand and the matching GitHub Action.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterable, List, Optional, Set, Tuple
|
|
21
|
+
|
|
22
|
+
LINK_PATTERN = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
|
|
23
|
+
|
|
24
|
+
DEFAULT_ALLOW_ORPHANS = (
|
|
25
|
+
"docs/decisions.md",
|
|
26
|
+
"docs/decisions-archive.md",
|
|
27
|
+
"docs/file-structure.md",
|
|
28
|
+
# The whole branch-decisions tree is aggregator-managed: files appear
|
|
29
|
+
# during a feature branch's lifetime and are linked into decisions.md
|
|
30
|
+
# only on PR merge. Trailing slash → directory prefix (any .md under it).
|
|
31
|
+
"docs/decisions-branches/",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
DEFAULT_ROOTS = ("README.md", "AGENTS.md", "CLAUDE.md", "docs")
|
|
35
|
+
|
|
36
|
+
_EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "ftp://", "//", "#")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_external(target: str) -> bool:
|
|
40
|
+
return target.startswith(_EXTERNAL_PREFIXES)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _strip_anchor(target: str) -> str:
|
|
44
|
+
return target.split("#", 1)[0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _collect_md_files(roots: Iterable[Path]) -> Set[Path]:
|
|
48
|
+
found: Set[Path] = set()
|
|
49
|
+
for root in roots:
|
|
50
|
+
if not root.exists():
|
|
51
|
+
continue
|
|
52
|
+
if root.is_file() and root.suffix == ".md":
|
|
53
|
+
found.add(root.resolve())
|
|
54
|
+
elif root.is_dir():
|
|
55
|
+
for p in root.rglob("*.md"):
|
|
56
|
+
found.add(p.resolve())
|
|
57
|
+
return found
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _extract_links(md_path: Path) -> List[Tuple[str, str]]:
|
|
61
|
+
try:
|
|
62
|
+
text = md_path.read_text(errors="ignore")
|
|
63
|
+
except OSError:
|
|
64
|
+
return []
|
|
65
|
+
return LINK_PATTERN.findall(text)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check(
|
|
69
|
+
repo_root: Path,
|
|
70
|
+
roots: Optional[Iterable[Path]] = None,
|
|
71
|
+
allow_orphans: Iterable[str] = DEFAULT_ALLOW_ORPHANS,
|
|
72
|
+
) -> Tuple[List[str], List[str]]:
|
|
73
|
+
"""
|
|
74
|
+
Run the link check against ``repo_root``.
|
|
75
|
+
|
|
76
|
+
Returns ``(broken, orphans)``. Each list is empty on a clean run; both
|
|
77
|
+
lists contain repo-relative path strings (broken includes the link text).
|
|
78
|
+
"""
|
|
79
|
+
repo_root = repo_root.resolve()
|
|
80
|
+
if roots is None:
|
|
81
|
+
roots = [repo_root / r for r in DEFAULT_ROOTS]
|
|
82
|
+
else:
|
|
83
|
+
roots = list(roots)
|
|
84
|
+
|
|
85
|
+
md_files = _collect_md_files(roots)
|
|
86
|
+
incoming = {p: set() for p in md_files}
|
|
87
|
+
|
|
88
|
+
broken: List[str] = []
|
|
89
|
+
for source in md_files:
|
|
90
|
+
for _text, target in _extract_links(source):
|
|
91
|
+
if _is_external(target):
|
|
92
|
+
continue
|
|
93
|
+
stripped = _strip_anchor(target)
|
|
94
|
+
if not stripped:
|
|
95
|
+
# Pure-anchor link to current file; skip.
|
|
96
|
+
continue
|
|
97
|
+
resolved = (source.parent / stripped).resolve()
|
|
98
|
+
if not resolved.exists():
|
|
99
|
+
try:
|
|
100
|
+
rel_source = source.relative_to(repo_root).as_posix()
|
|
101
|
+
except ValueError:
|
|
102
|
+
rel_source = source.as_posix()
|
|
103
|
+
broken.append(f"{rel_source}: missing -> {target}")
|
|
104
|
+
continue
|
|
105
|
+
if resolved.suffix == ".md" and resolved in incoming:
|
|
106
|
+
incoming[resolved].add(source)
|
|
107
|
+
|
|
108
|
+
orphans: List[str] = []
|
|
109
|
+
for md, sources in incoming.items():
|
|
110
|
+
try:
|
|
111
|
+
rel = md.relative_to(repo_root)
|
|
112
|
+
except ValueError:
|
|
113
|
+
continue
|
|
114
|
+
if not rel.parts or rel.parts[0] != "docs":
|
|
115
|
+
continue
|
|
116
|
+
if _is_allowed_orphan(rel, allow_orphans):
|
|
117
|
+
continue
|
|
118
|
+
if not sources:
|
|
119
|
+
orphans.append(rel.as_posix())
|
|
120
|
+
|
|
121
|
+
return sorted(broken), sorted(orphans)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_allowed_orphan(rel_path: Path, allow_orphans: Iterable[str]) -> bool:
|
|
125
|
+
"""An entry matches if it equals rel_path OR is a parent directory of it.
|
|
126
|
+
|
|
127
|
+
Directory prefixes are signalled by a trailing ``/`` in the entry, but
|
|
128
|
+
we also tolerate plain dir paths (no slash) by checking the exact match
|
|
129
|
+
against parents. Comparison is done in POSIX form so this works on
|
|
130
|
+
Windows where Path renders with backslashes.
|
|
131
|
+
"""
|
|
132
|
+
s = rel_path.as_posix()
|
|
133
|
+
for entry in allow_orphans:
|
|
134
|
+
e = entry.rstrip("/")
|
|
135
|
+
if s == e:
|
|
136
|
+
return True
|
|
137
|
+
if s.startswith(e + "/"):
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def format_report(broken: List[str], orphans: List[str]) -> str:
|
|
143
|
+
lines: List[str] = []
|
|
144
|
+
if broken:
|
|
145
|
+
lines.append(f"Broken links ({len(broken)}):")
|
|
146
|
+
for b in broken:
|
|
147
|
+
lines.append(f" - {b}")
|
|
148
|
+
if orphans:
|
|
149
|
+
if lines:
|
|
150
|
+
lines.append("")
|
|
151
|
+
lines.append(f"Orphan markdown files ({len(orphans)}):")
|
|
152
|
+
for o in orphans:
|
|
153
|
+
lines.append(f" - {o}")
|
|
154
|
+
if not lines:
|
|
155
|
+
lines.append("All markdown links resolve and no orphans found.")
|
|
156
|
+
return "\n".join(lines)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
160
|
+
"""Entry point for ``logmind check-links`` and the GH Action.
|
|
161
|
+
|
|
162
|
+
Always operates on the current working directory. GH Actions runs
|
|
163
|
+
``run:`` steps with cwd set to ``$GITHUB_WORKSPACE`` automatically, so
|
|
164
|
+
we don't need to special-case it (and reading the env var would
|
|
165
|
+
actually defeat tests that monkeypatch.chdir into a temp dir while
|
|
166
|
+
running on a CI runner that has GITHUB_WORKSPACE pointed elsewhere).
|
|
167
|
+
"""
|
|
168
|
+
repo_root = Path(os.getcwd())
|
|
169
|
+
|
|
170
|
+
# Optional config overrides via .logmind/config.yml
|
|
171
|
+
allow_orphans = list(DEFAULT_ALLOW_ORPHANS)
|
|
172
|
+
roots: Optional[List[Path]] = None
|
|
173
|
+
config_path = repo_root / ".logmind" / "config.yml"
|
|
174
|
+
if config_path.exists():
|
|
175
|
+
try:
|
|
176
|
+
from logmind.core.config import load_config
|
|
177
|
+
|
|
178
|
+
cfg = load_config(config_path)
|
|
179
|
+
extra = cfg.get("linkcheck.allow_orphans") or []
|
|
180
|
+
allow_orphans = list(DEFAULT_ALLOW_ORPHANS) + list(extra)
|
|
181
|
+
configured_roots = cfg.get("linkcheck.roots") or []
|
|
182
|
+
if configured_roots:
|
|
183
|
+
roots = [repo_root / r for r in configured_roots]
|
|
184
|
+
except Exception:
|
|
185
|
+
pass # ignore config errors; defaults still work
|
|
186
|
+
|
|
187
|
+
broken, orphans = check(repo_root, roots=roots, allow_orphans=allow_orphans)
|
|
188
|
+
print(format_report(broken, orphans))
|
|
189
|
+
return 0 if not broken and not orphans else 1
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__": # pragma: no cover
|
|
193
|
+
sys.exit(main())
|