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.
Files changed (38) hide show
  1. logmind/__init__.py +8 -0
  2. logmind/actions/__init__.py +5 -0
  3. logmind/actions/aggregate.py +108 -0
  4. logmind/actions/link_check.py +193 -0
  5. logmind/cli.py +1224 -0
  6. logmind/core/__init__.py +1 -0
  7. logmind/core/aggregator.py +78 -0
  8. logmind/core/analytics.py +97 -0
  9. logmind/core/config.py +233 -0
  10. logmind/core/decision_templates.py +86 -0
  11. logmind/core/git_handler.py +277 -0
  12. logmind/core/gitignore.py +60 -0
  13. logmind/core/inserter.py +623 -0
  14. logmind/core/logger.py +323 -0
  15. logmind/core/parser.py +22 -0
  16. logmind/core/search.py +192 -0
  17. logmind/core/skill_install.py +68 -0
  18. logmind/core/tree_gen.py +208 -0
  19. logmind/decorators.py +181 -0
  20. logmind/integrations/__init__.py +5 -0
  21. logmind/integrations/base.py +43 -0
  22. logmind/integrations/langchain.py +79 -0
  23. logmind/templates/AGENTS.md.template +81 -0
  24. logmind/templates/CLAUDE.md.template +53 -0
  25. logmind/templates/agent-stub.md +3 -0
  26. logmind/templates/config.yml.template +61 -0
  27. logmind/templates/decisions-archive.md.template +7 -0
  28. logmind/templates/decisions.md.template +5 -0
  29. logmind/templates/file-structure.md.template +9 -0
  30. logmind/templates/github/check-doc-links.yml.template +31 -0
  31. logmind/templates/github/logmind-aggregate.yml.template +49 -0
  32. logmind/templates/logmind-section.md +68 -0
  33. logmind-0.1.0.dist-info/METADATA +219 -0
  34. logmind-0.1.0.dist-info/RECORD +38 -0
  35. logmind-0.1.0.dist-info/WHEEL +5 -0
  36. logmind-0.1.0.dist-info/entry_points.txt +2 -0
  37. logmind-0.1.0.dist-info/licenses/LICENSE +21 -0
  38. logmind-0.1.0.dist-info/top_level.txt +1 -0
logmind/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """logmind - AI decision logging system for development projects."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from logmind.core.logger import log
6
+ from logmind.decorators import log_choice, log_decision
7
+
8
+ __all__ = ["log", "log_decision", "log_choice"]
@@ -0,0 +1,5 @@
1
+ """GitHub Action entry points shipped with logmind.
2
+
3
+ These modules are designed to run inside CI (`python -m logmind.actions.<name>`)
4
+ but are also importable so they can be unit-tested without a full CI harness.
5
+ """
@@ -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())