codealmanac 0.1.0.dev0__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 (192) hide show
  1. codealmanac/__init__.py +13 -0
  2. codealmanac/app.py +175 -0
  3. codealmanac/cli/__init__.py +1 -0
  4. codealmanac/cli/dispatch/__init__.py +0 -0
  5. codealmanac/cli/dispatch/admin.py +124 -0
  6. codealmanac/cli/dispatch/config.py +50 -0
  7. codealmanac/cli/dispatch/root.py +328 -0
  8. codealmanac/cli/main.py +28 -0
  9. codealmanac/cli/parser/__init__.py +0 -0
  10. codealmanac/cli/parser/admin.py +81 -0
  11. codealmanac/cli/parser/lifecycle.py +57 -0
  12. codealmanac/cli/parser/root.py +19 -0
  13. codealmanac/cli/parser/wiki.py +87 -0
  14. codealmanac/cli/render/__init__.py +0 -0
  15. codealmanac/cli/render/admin.py +191 -0
  16. codealmanac/cli/render/root.py +290 -0
  17. codealmanac/core/__init__.py +1 -0
  18. codealmanac/core/errors.py +45 -0
  19. codealmanac/core/models.py +14 -0
  20. codealmanac/core/paths.py +25 -0
  21. codealmanac/core/slug.py +7 -0
  22. codealmanac/core/text.py +5 -0
  23. codealmanac/database/__init__.py +15 -0
  24. codealmanac/database/sqlite.py +54 -0
  25. codealmanac/integrations/__init__.py +1 -0
  26. codealmanac/integrations/automation/__init__.py +3 -0
  27. codealmanac/integrations/automation/scheduler/__init__.py +5 -0
  28. codealmanac/integrations/automation/scheduler/launchd.py +163 -0
  29. codealmanac/integrations/command.py +56 -0
  30. codealmanac/integrations/harnesses/__init__.py +7 -0
  31. codealmanac/integrations/harnesses/claude/__init__.py +1 -0
  32. codealmanac/integrations/harnesses/claude/adapter.py +217 -0
  33. codealmanac/integrations/harnesses/codex/__init__.py +3 -0
  34. codealmanac/integrations/harnesses/codex/adapter.py +221 -0
  35. codealmanac/integrations/harnesses/git_status.py +49 -0
  36. codealmanac/integrations/sources/__init__.py +29 -0
  37. codealmanac/integrations/sources/filesystem/__init__.py +5 -0
  38. codealmanac/integrations/sources/filesystem/adapter.py +685 -0
  39. codealmanac/integrations/sources/filesystem/selection.py +209 -0
  40. codealmanac/integrations/sources/git/__init__.py +3 -0
  41. codealmanac/integrations/sources/git/adapter.py +132 -0
  42. codealmanac/integrations/sources/github/__init__.py +3 -0
  43. codealmanac/integrations/sources/github/adapter.py +413 -0
  44. codealmanac/integrations/sources/runtime.py +22 -0
  45. codealmanac/integrations/sources/transcripts/__init__.py +33 -0
  46. codealmanac/integrations/sources/transcripts/claude.py +61 -0
  47. codealmanac/integrations/sources/transcripts/codex.py +69 -0
  48. codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
  49. codealmanac/integrations/sources/transcripts/runtime.py +387 -0
  50. codealmanac/integrations/sources/web/__init__.py +3 -0
  51. codealmanac/integrations/sources/web/adapter.py +303 -0
  52. codealmanac/integrations/updates/__init__.py +7 -0
  53. codealmanac/integrations/updates/package.py +85 -0
  54. codealmanac/integrations/workspaces/__init__.py +1 -0
  55. codealmanac/integrations/workspaces/git/__init__.py +3 -0
  56. codealmanac/integrations/workspaces/git/probe.py +128 -0
  57. codealmanac/manual/README.md +24 -0
  58. codealmanac/manual/__init__.py +19 -0
  59. codealmanac/manual/build.md +20 -0
  60. codealmanac/manual/evidence.md +23 -0
  61. codealmanac/manual/garden.md +20 -0
  62. codealmanac/manual/ingest.md +17 -0
  63. codealmanac/manual/library.py +84 -0
  64. codealmanac/manual/models.py +83 -0
  65. codealmanac/manual/pages.md +28 -0
  66. codealmanac/manual/requests.py +6 -0
  67. codealmanac/manual/sources.md +18 -0
  68. codealmanac/manual/style.md +19 -0
  69. codealmanac/prompts/__init__.py +5 -0
  70. codealmanac/prompts/base/notability.md +14 -0
  71. codealmanac/prompts/base/purpose.md +23 -0
  72. codealmanac/prompts/base/syntax.md +19 -0
  73. codealmanac/prompts/models.py +9 -0
  74. codealmanac/prompts/operations/garden.md +26 -0
  75. codealmanac/prompts/operations/ingest.md +18 -0
  76. codealmanac/prompts/renderer.py +24 -0
  77. codealmanac/prompts/requests.py +22 -0
  78. codealmanac/server/__init__.py +1 -0
  79. codealmanac/server/app.py +202 -0
  80. codealmanac/server/assets/__init__.py +1 -0
  81. codealmanac/server/assets/app.css +865 -0
  82. codealmanac/server/assets/app.js +3 -0
  83. codealmanac/server/assets/index.html +80 -0
  84. codealmanac/server/assets/viewer/api.js +30 -0
  85. codealmanac/server/assets/viewer/components.js +197 -0
  86. codealmanac/server/assets/viewer/main.js +126 -0
  87. codealmanac/server/assets/viewer/renderers.js +122 -0
  88. codealmanac/server/assets/viewer/routes.js +36 -0
  89. codealmanac/services/__init__.py +1 -0
  90. codealmanac/services/automation/__init__.py +3 -0
  91. codealmanac/services/automation/models.py +83 -0
  92. codealmanac/services/automation/ports.py +14 -0
  93. codealmanac/services/automation/requests.py +40 -0
  94. codealmanac/services/automation/service.py +294 -0
  95. codealmanac/services/config/__init__.py +17 -0
  96. codealmanac/services/config/models.py +61 -0
  97. codealmanac/services/config/requests.py +21 -0
  98. codealmanac/services/config/service.py +55 -0
  99. codealmanac/services/config/store.py +26 -0
  100. codealmanac/services/diagnostics/__init__.py +1 -0
  101. codealmanac/services/diagnostics/models.py +22 -0
  102. codealmanac/services/diagnostics/requests.py +8 -0
  103. codealmanac/services/diagnostics/service.py +283 -0
  104. codealmanac/services/harnesses/__init__.py +1 -0
  105. codealmanac/services/harnesses/models.py +104 -0
  106. codealmanac/services/harnesses/ports.py +18 -0
  107. codealmanac/services/harnesses/requests.py +19 -0
  108. codealmanac/services/harnesses/service.py +38 -0
  109. codealmanac/services/health/__init__.py +1 -0
  110. codealmanac/services/health/requests.py +8 -0
  111. codealmanac/services/health/service.py +20 -0
  112. codealmanac/services/index/__init__.py +1 -0
  113. codealmanac/services/index/models.py +135 -0
  114. codealmanac/services/index/requests.py +26 -0
  115. codealmanac/services/index/service.py +86 -0
  116. codealmanac/services/index/store.py +411 -0
  117. codealmanac/services/index/views.py +524 -0
  118. codealmanac/services/pages/__init__.py +1 -0
  119. codealmanac/services/pages/requests.py +17 -0
  120. codealmanac/services/pages/service.py +26 -0
  121. codealmanac/services/runs/__init__.py +1 -0
  122. codealmanac/services/runs/models.py +91 -0
  123. codealmanac/services/runs/requests.py +76 -0
  124. codealmanac/services/runs/service.py +86 -0
  125. codealmanac/services/runs/store.py +256 -0
  126. codealmanac/services/search/__init__.py +1 -0
  127. codealmanac/services/search/requests.py +23 -0
  128. codealmanac/services/search/service.py +31 -0
  129. codealmanac/services/sources/__init__.py +1 -0
  130. codealmanac/services/sources/models.py +126 -0
  131. codealmanac/services/sources/ports.py +30 -0
  132. codealmanac/services/sources/requests.py +76 -0
  133. codealmanac/services/sources/service.py +351 -0
  134. codealmanac/services/tagging/__init__.py +1 -0
  135. codealmanac/services/tagging/models.py +9 -0
  136. codealmanac/services/tagging/requests.py +35 -0
  137. codealmanac/services/tagging/service.py +43 -0
  138. codealmanac/services/topics/__init__.py +1 -0
  139. codealmanac/services/topics/models.py +36 -0
  140. codealmanac/services/topics/requests.py +115 -0
  141. codealmanac/services/topics/service.py +297 -0
  142. codealmanac/services/updates/__init__.py +4 -0
  143. codealmanac/services/updates/models.py +83 -0
  144. codealmanac/services/updates/ports.py +17 -0
  145. codealmanac/services/updates/requests.py +10 -0
  146. codealmanac/services/updates/service.py +113 -0
  147. codealmanac/services/viewer/__init__.py +1 -0
  148. codealmanac/services/viewer/models.py +80 -0
  149. codealmanac/services/viewer/renderer.py +89 -0
  150. codealmanac/services/viewer/requests.py +86 -0
  151. codealmanac/services/viewer/service.py +211 -0
  152. codealmanac/services/wiki/__init__.py +1 -0
  153. codealmanac/services/wiki/documents.py +83 -0
  154. codealmanac/services/wiki/frontmatter.py +94 -0
  155. codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
  156. codealmanac/services/wiki/models.py +69 -0
  157. codealmanac/services/wiki/paths.py +42 -0
  158. codealmanac/services/wiki/service.py +57 -0
  159. codealmanac/services/wiki/templates.py +73 -0
  160. codealmanac/services/wiki/topics.py +266 -0
  161. codealmanac/services/wiki/wikilinks.py +58 -0
  162. codealmanac/services/workspaces/__init__.py +1 -0
  163. codealmanac/services/workspaces/models.py +124 -0
  164. codealmanac/services/workspaces/ports.py +9 -0
  165. codealmanac/services/workspaces/requests.py +82 -0
  166. codealmanac/services/workspaces/roots.py +74 -0
  167. codealmanac/services/workspaces/service.py +303 -0
  168. codealmanac/services/workspaces/store.py +127 -0
  169. codealmanac/workflows/__init__.py +1 -0
  170. codealmanac/workflows/build/__init__.py +1 -0
  171. codealmanac/workflows/build/models.py +8 -0
  172. codealmanac/workflows/build/service.py +45 -0
  173. codealmanac/workflows/garden/__init__.py +3 -0
  174. codealmanac/workflows/garden/models.py +30 -0
  175. codealmanac/workflows/garden/requests.py +22 -0
  176. codealmanac/workflows/garden/service.py +239 -0
  177. codealmanac/workflows/ingest/__init__.py +1 -0
  178. codealmanac/workflows/ingest/models.py +26 -0
  179. codealmanac/workflows/ingest/requests.py +39 -0
  180. codealmanac/workflows/ingest/service.py +302 -0
  181. codealmanac/workflows/lifecycle.py +197 -0
  182. codealmanac/workflows/sync/__init__.py +3 -0
  183. codealmanac/workflows/sync/models.py +157 -0
  184. codealmanac/workflows/sync/requests.py +63 -0
  185. codealmanac/workflows/sync/service.py +651 -0
  186. codealmanac/workflows/sync/store.py +51 -0
  187. codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
  188. codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
  189. codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
  190. codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  191. codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
  192. codealmanac-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,42 @@
1
+ import re
2
+
3
+
4
+ def looks_like_dir(raw: str) -> bool:
5
+ return raw.strip().replace("\\", "/").endswith("/")
6
+
7
+
8
+ def normalize_reference_path(raw: str, is_dir: bool) -> str:
9
+ return normalize_reference_shape(raw, is_dir).casefold()
10
+
11
+
12
+ def normalize_reference_path_preserving_case(raw: str, is_dir: bool) -> str:
13
+ return normalize_reference_shape(raw, is_dir)
14
+
15
+
16
+ def normalize_reference_shape(raw: str, is_dir: bool) -> str:
17
+ text = raw.strip().replace("\\", "/")
18
+ while text.startswith("./"):
19
+ text = text[2:]
20
+ text = re.sub(r"/+", "/", text)
21
+ text = text.lstrip("/")
22
+ if ".." in text.split("/"):
23
+ return ""
24
+ text = text.rstrip("/")
25
+ if is_dir and text:
26
+ return f"{text}/"
27
+ return text
28
+
29
+
30
+ def parent_folder_prefixes(file_path: str) -> list[str]:
31
+ prefixes: list[str] = []
32
+ cursor = 0
33
+ while True:
34
+ index = file_path.find("/", cursor)
35
+ if index == -1:
36
+ return prefixes
37
+ prefixes.append(file_path[: index + 1])
38
+ cursor = index + 1
39
+
40
+
41
+ def escape_glob_meta(input_path: str) -> str:
42
+ return re.sub(r"([*?\[])", r"[\1]", input_path)
@@ -0,0 +1,57 @@
1
+ from pathlib import Path
2
+
3
+ from codealmanac.manual import ManualLibrary
4
+ from codealmanac.services.wiki.templates import (
5
+ gitignore_runtime_block,
6
+ starter_page,
7
+ starter_readme,
8
+ starter_topics_yaml,
9
+ )
10
+ from codealmanac.services.workspaces.service import WorkspacesService
11
+
12
+
13
+ class WikiService:
14
+ def __init__(self, workspaces: WorkspacesService, manual: ManualLibrary):
15
+ self.workspaces = workspaces
16
+ self.manual = manual
17
+
18
+ def initialize(self, workspace_id: str) -> None:
19
+ workspace = self.workspaces.get(workspace_id)
20
+ almanac_path = workspace.almanac_path
21
+ pages_path = almanac_path / "pages"
22
+ manual_path = almanac_path / "manual"
23
+ almanac_path.mkdir(parents=True, exist_ok=True)
24
+ pages_path.mkdir(parents=True, exist_ok=True)
25
+ manual_path.mkdir(parents=True, exist_ok=True)
26
+ write_if_missing(almanac_path / "README.md", starter_readme())
27
+ write_if_missing(almanac_path / "topics.yaml", starter_topics_yaml())
28
+ write_if_missing(pages_path / "getting-started.md", starter_page())
29
+ self.manual.install_missing(manual_path)
30
+ ensure_root_gitignore(workspace.root_path, workspace.almanac_root)
31
+
32
+
33
+ def write_if_missing(path: Path, body: str) -> None:
34
+ if path.exists():
35
+ return
36
+ path.write_text(body, encoding="utf-8")
37
+
38
+
39
+ def ensure_root_gitignore(root_path: Path, almanac_root: Path) -> None:
40
+ path = root_path / ".gitignore"
41
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
42
+ lines = {line.strip() for line in existing.splitlines()}
43
+ missing = [
44
+ line
45
+ for line in gitignore_runtime_block(almanac_root)
46
+ if line not in lines
47
+ ]
48
+ if len(missing) == 0:
49
+ return
50
+ header = "# codealmanac"
51
+ block_lines = []
52
+ if header not in lines:
53
+ block_lines.append(header)
54
+ block_lines.extend(missing)
55
+ block = "\n".join(block_lines) + "\n"
56
+ separator = "" if existing == "" else "\n" if existing.endswith("\n") else "\n\n"
57
+ path.write_text(f"{existing}{separator}{block}", encoding="utf-8")
@@ -0,0 +1,73 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def starter_readme() -> str:
5
+ return """# CodeAlmanac Wiki
6
+
7
+ This is the living wiki for this repository. It records the durable knowledge
8
+ the code cannot say: decisions, flows, invariants, incidents, gotchas, and
9
+ project context that future agents should not rediscover from scratch.
10
+
11
+ ## Notability Bar
12
+
13
+ Write a page when it preserves non-obvious knowledge that will help a future
14
+ agent work safely in this codebase.
15
+
16
+ Good pages explain:
17
+
18
+ - a decision that took research or trial-and-error
19
+ - a cross-file flow
20
+ - an invariant or gotcha not visible from one file
21
+ - an external dependency as this repo uses it
22
+ - a product or operational constraint that shapes future work
23
+
24
+ Do not write pages that restate nearby code.
25
+
26
+ ## Topic Taxonomy
27
+
28
+ Topics live in `topics.yaml`. Pages live in `pages/`.
29
+
30
+ ## Manual
31
+
32
+ Read `manual/README.md` before creating, reorganizing, or substantially
33
+ rewriting pages. The manual is bundled with CodeAlmanac and copied here by
34
+ `codealmanac init` and `codealmanac build`.
35
+
36
+ ## Links
37
+
38
+ Use `[[page-slug]]` for page links and `[[src/path.py]]` for file references.
39
+ """
40
+
41
+
42
+ def starter_topics_yaml() -> str:
43
+ return """topics:
44
+ - slug: concepts
45
+ title: Concepts
46
+ description: Core vocabulary and mental models for this codebase
47
+ parents: []
48
+ """
49
+
50
+
51
+ def starter_page() -> str:
52
+ return """---
53
+ title: Getting Started
54
+ topics: [concepts]
55
+ sources: []
56
+ status: active
57
+ ---
58
+
59
+ # Getting Started
60
+
61
+ This starter page marks the wiki as initialized. Replace it with the first
62
+ durable reading path for this repository.
63
+ """
64
+
65
+
66
+ def gitignore_runtime_block(almanac_root: Path) -> list[str]:
67
+ root = almanac_root.as_posix().rstrip("/")
68
+ return [
69
+ f"{root}/index.db",
70
+ f"{root}/index.db-wal",
71
+ f"{root}/index.db-shm",
72
+ f"{root}/jobs/",
73
+ ]
@@ -0,0 +1,266 @@
1
+ from io import StringIO
2
+ from pathlib import Path
3
+ from typing import Any
4
+ from uuid import uuid4
5
+
6
+ import yaml as pyyaml
7
+ from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
8
+ from ruamel.yaml import YAML
9
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
10
+ from yaml import YAMLError
11
+
12
+ from codealmanac.core.errors import ValidationFailed
13
+ from codealmanac.core.slug import to_kebab_case
14
+
15
+
16
+ class TopicDefinition(BaseModel):
17
+ model_config = ConfigDict(extra="ignore", frozen=True)
18
+
19
+ slug: str
20
+ title: str | None = None
21
+ description: str | None = None
22
+ parents: tuple[str, ...] = ()
23
+
24
+ @field_validator("slug", mode="before")
25
+ @classmethod
26
+ def canonical_slug(cls, value: Any) -> str:
27
+ return to_kebab_case(str(value))
28
+
29
+ @field_validator("title", "description", mode="before")
30
+ @classmethod
31
+ def optional_text(cls, value: Any) -> str | None:
32
+ if isinstance(value, str) and value.strip():
33
+ return value.strip()
34
+ return None
35
+
36
+ @field_validator("parents", mode="before")
37
+ @classmethod
38
+ def parent_slugs(cls, value: Any) -> tuple[str, ...]:
39
+ if not isinstance(value, list | tuple):
40
+ return ()
41
+ parents: list[str] = []
42
+ for item in value:
43
+ slug = to_kebab_case(str(item))
44
+ if slug:
45
+ parents.append(slug)
46
+ return tuple(dict.fromkeys(parents))
47
+
48
+
49
+ class TopicsYaml(BaseModel):
50
+ model_config = ConfigDict(extra="ignore", frozen=True)
51
+
52
+ topics: tuple[TopicDefinition, ...] = ()
53
+
54
+
55
+ def load_topics_yaml(almanac_path: Path) -> tuple[TopicDefinition, ...]:
56
+ path = almanac_path / "topics.yaml"
57
+ if not path.is_file():
58
+ return ()
59
+ try:
60
+ parsed = pyyaml.safe_load(path.read_text(encoding="utf-8")) or {}
61
+ except (OSError, YAMLError):
62
+ return ()
63
+ if not isinstance(parsed, dict):
64
+ return ()
65
+ try:
66
+ model = TopicsYaml.model_validate(parsed)
67
+ except ValidationError:
68
+ return ()
69
+ return tuple(topic for topic in model.topics if topic.slug)
70
+
71
+
72
+ class TopicsYamlFile:
73
+ def __init__(
74
+ self,
75
+ path: Path,
76
+ data: CommentedMap,
77
+ topics: CommentedSeq,
78
+ line_ending: str,
79
+ ):
80
+ self.path = path
81
+ self.data = data
82
+ self.topics = topics
83
+ self.line_ending = line_ending
84
+
85
+ @property
86
+ def definitions(self) -> tuple[TopicDefinition, ...]:
87
+ try:
88
+ model = TopicsYaml.model_validate(self.data)
89
+ except ValidationError as error:
90
+ raise ValidationFailed(f"invalid topics.yaml: {error}") from error
91
+ return tuple(topic for topic in model.topics if topic.slug)
92
+
93
+ def has_entry(self, slug: str) -> bool:
94
+ return self.entry_for(slug) is not None
95
+
96
+ def ensure_topic(self, slug: str, title: str | None = None) -> None:
97
+ if self.has_entry(slug):
98
+ return
99
+ entry = CommentedMap()
100
+ entry["slug"] = slug
101
+ entry["title"] = title or title_for_slug(slug)
102
+ entry["parents"] = CommentedSeq()
103
+ self.topics.append(entry)
104
+
105
+ def set_description(self, slug: str, description: str | None) -> None:
106
+ entry = self.required_entry(slug)
107
+ if description:
108
+ entry["description"] = description
109
+ return
110
+ if "description" in entry:
111
+ del entry["description"]
112
+
113
+ def maybe_update_default_title(self, slug: str, title: str) -> None:
114
+ entry = self.required_entry(slug)
115
+ default_title = title_for_slug(slug)
116
+ current_title = entry.get("title")
117
+ if current_title in (None, default_title) and title != default_title:
118
+ entry["title"] = title
119
+
120
+ def add_parent(self, child: str, parent: str) -> bool:
121
+ entry = self.required_entry(child)
122
+ parents = parent_sequence(entry)
123
+ if parent in {str(item) for item in parents}:
124
+ return False
125
+ parents.append(parent)
126
+ entry["parents"] = parents
127
+ return True
128
+
129
+ def remove_parent(self, child: str, parent: str) -> bool:
130
+ entry = self.entry_for(child)
131
+ if entry is None:
132
+ return False
133
+ parents = parent_sequence(entry)
134
+ removed = False
135
+ for index in range(len(parents) - 1, -1, -1):
136
+ if str(parents[index]) == parent:
137
+ del parents[index]
138
+ removed = True
139
+ entry["parents"] = parents
140
+ return removed
141
+
142
+ def rename_topic(self, old_slug: str, new_slug: str) -> bool:
143
+ changed = False
144
+ entry = self.entry_for(old_slug)
145
+ if entry is not None:
146
+ entry["slug"] = new_slug
147
+ if entry.get("title") == title_for_slug(old_slug):
148
+ entry["title"] = title_for_slug(new_slug)
149
+ changed = True
150
+ for item in self.topics:
151
+ if not isinstance(item, CommentedMap):
152
+ continue
153
+ parents = parent_sequence(item)
154
+ next_parents = replace_parent_slug(parents, old_slug, new_slug)
155
+ if next_parents != tuple(str(parent) for parent in parents):
156
+ item["parents"] = CommentedSeq(next_parents)
157
+ changed = True
158
+ return changed
159
+
160
+ def delete_topic(self, slug: str) -> bool:
161
+ changed = False
162
+ for index in range(len(self.topics) - 1, -1, -1):
163
+ item = self.topics[index]
164
+ if (
165
+ isinstance(item, CommentedMap)
166
+ and to_kebab_case(str(item.get("slug"))) == slug
167
+ ):
168
+ del self.topics[index]
169
+ changed = True
170
+ for item in self.topics:
171
+ if not isinstance(item, CommentedMap):
172
+ continue
173
+ parents = parent_sequence(item)
174
+ next_parents = tuple(
175
+ str(parent) for parent in parents if str(parent) != slug
176
+ )
177
+ if next_parents != tuple(str(parent) for parent in parents):
178
+ item["parents"] = CommentedSeq(next_parents)
179
+ changed = True
180
+ return changed
181
+
182
+ def write(self) -> None:
183
+ self.path.parent.mkdir(parents=True, exist_ok=True)
184
+ yaml = YAML(typ="rt")
185
+ yaml.preserve_quotes = True
186
+ output = StringIO()
187
+ yaml.dump(self.data, output)
188
+ text = output.getvalue()
189
+ if self.line_ending != "\n":
190
+ text = text.replace("\n", self.line_ending)
191
+ temporary = self.path.with_name(f".{self.path.name}.{uuid4().hex}.tmp")
192
+ temporary.write_text(text, encoding="utf-8")
193
+ temporary.replace(self.path)
194
+
195
+ def required_entry(self, slug: str) -> CommentedMap:
196
+ entry = self.entry_for(slug)
197
+ if entry is None:
198
+ raise ValidationFailed(f'topic "{slug}" is missing from topics.yaml')
199
+ return entry
200
+
201
+ def entry_for(self, slug: str) -> CommentedMap | None:
202
+ for item in self.topics:
203
+ if (
204
+ isinstance(item, CommentedMap)
205
+ and to_kebab_case(str(item.get("slug"))) == slug
206
+ ):
207
+ return item
208
+ return None
209
+
210
+
211
+ def load_topics_file(almanac_path: Path) -> TopicsYamlFile:
212
+ path = almanac_path / "topics.yaml"
213
+ raw = read_topics_text(path)
214
+ line_ending = "\r\n" if "\r\n" in raw else "\n"
215
+ yaml = YAML(typ="rt")
216
+ yaml.preserve_quotes = True
217
+ try:
218
+ parsed = yaml.load(raw) if raw.strip() else CommentedMap()
219
+ except Exception as error:
220
+ raise ValidationFailed(f"invalid topics.yaml: {path}") from error
221
+ if parsed is None:
222
+ parsed = CommentedMap()
223
+ if not isinstance(parsed, CommentedMap):
224
+ raise ValidationFailed(f"topics.yaml must be a YAML mapping: {path}")
225
+ topics = parsed.get("topics")
226
+ if topics is None:
227
+ topics = CommentedSeq()
228
+ parsed["topics"] = topics
229
+ if not isinstance(topics, CommentedSeq):
230
+ raise ValidationFailed(f"topics.yaml topics must be a list: {path}")
231
+ file = TopicsYamlFile(path, parsed, topics, line_ending)
232
+ _ = file.definitions
233
+ return file
234
+
235
+
236
+ def read_topics_text(path: Path) -> str:
237
+ if not path.is_file():
238
+ return ""
239
+ return path.read_text(encoding="utf-8")
240
+
241
+
242
+ def parent_sequence(entry: CommentedMap) -> CommentedSeq:
243
+ existing = entry.get("parents")
244
+ if isinstance(existing, CommentedSeq):
245
+ return existing
246
+ sequence = CommentedSeq()
247
+ if isinstance(existing, list):
248
+ sequence.extend(to_kebab_case(str(item)) for item in existing)
249
+ return sequence
250
+
251
+
252
+ def replace_parent_slug(
253
+ parents: CommentedSeq,
254
+ old_slug: str,
255
+ new_slug: str,
256
+ ) -> tuple[str, ...]:
257
+ replaced: list[str] = []
258
+ for parent in parents:
259
+ next_parent = new_slug if str(parent) == old_slug else str(parent)
260
+ if next_parent not in replaced:
261
+ replaced.append(next_parent)
262
+ return tuple(replaced)
263
+
264
+
265
+ def title_for_slug(slug: str) -> str:
266
+ return " ".join(part.capitalize() for part in slug.split("-") if part)
@@ -0,0 +1,58 @@
1
+ import re
2
+
3
+ from codealmanac.core.slug import to_kebab_case
4
+ from codealmanac.services.wiki.models import (
5
+ CrossWikiLink,
6
+ FileLink,
7
+ FileReference,
8
+ FolderLink,
9
+ PageLink,
10
+ Wikilink,
11
+ WikilinkKind,
12
+ )
13
+ from codealmanac.services.wiki.paths import (
14
+ looks_like_dir,
15
+ normalize_reference_path,
16
+ normalize_reference_path_preserving_case,
17
+ )
18
+
19
+
20
+ def classify_wikilink(raw: str) -> Wikilink | None:
21
+ target = raw.split("|", maxsplit=1)[0].strip()
22
+ if not target:
23
+ return None
24
+
25
+ first_colon = target.find(":")
26
+ first_slash = target.find("/")
27
+
28
+ if first_colon != -1 and (first_slash == -1 or first_colon < first_slash):
29
+ wiki = target[:first_colon].strip()
30
+ slug = target[first_colon + 1 :].strip()
31
+ if not wiki or not slug:
32
+ return None
33
+ return CrossWikiLink(kind=WikilinkKind.CROSS_WIKI, wiki=wiki, target=slug)
34
+
35
+ if first_slash != -1:
36
+ is_dir = looks_like_dir(target)
37
+ normalized = normalize_reference_path(target, is_dir)
38
+ original = normalize_reference_path_preserving_case(target, is_dir)
39
+ if not normalized:
40
+ return None
41
+ ref = FileReference(path=normalized, original_path=original, is_dir=is_dir)
42
+ if is_dir:
43
+ return FolderLink(kind=WikilinkKind.FOLDER, ref=ref)
44
+ return FileLink(kind=WikilinkKind.FILE, ref=ref)
45
+
46
+ slug = to_kebab_case(target)
47
+ if not slug:
48
+ return None
49
+ return PageLink(kind=WikilinkKind.PAGE, target=slug)
50
+
51
+
52
+ def extract_wikilinks(body: str) -> tuple[Wikilink, ...]:
53
+ links: list[Wikilink] = []
54
+ for match in re.finditer(r"\[\[([^\]\n]+)\]\]", body):
55
+ link = classify_wikilink(match.group(1))
56
+ if link is not None:
57
+ links.append(link)
58
+ return tuple(links)
@@ -0,0 +1,124 @@
1
+ from datetime import datetime
2
+ from enum import StrEnum
3
+ from pathlib import Path
4
+
5
+ from pydantic import Field, field_validator, model_validator
6
+
7
+ from codealmanac.core.models import CodeAlmanacModel
8
+ from codealmanac.core.text import required_text
9
+ from codealmanac.services.workspaces.roots import (
10
+ DEFAULT_ALMANAC_ROOT,
11
+ validate_almanac_root_field,
12
+ )
13
+
14
+
15
+ class Workspace(CodeAlmanacModel):
16
+ workspace_id: str
17
+ name: str
18
+ description: str
19
+ root_path: Path
20
+ almanac_root: Path = Field(default=DEFAULT_ALMANAC_ROOT)
21
+ almanac_path: Path
22
+ registered_at: datetime
23
+
24
+ @field_validator("workspace_id")
25
+ @classmethod
26
+ def require_workspace_id(cls, value: str) -> str:
27
+ return required_text(value, "workspace_id")
28
+
29
+ @field_validator("name")
30
+ @classmethod
31
+ def require_name(cls, value: str) -> str:
32
+ return required_text(value, "workspace name")
33
+
34
+ @field_validator("almanac_root")
35
+ @classmethod
36
+ def validate_almanac_root(cls, value: Path) -> Path:
37
+ return validate_almanac_root_field(value)
38
+
39
+ @model_validator(mode="after")
40
+ def validate_almanac_path_matches_root(self) -> "Workspace":
41
+ expected = self.root_path / self.almanac_root
42
+ if self.almanac_path != expected:
43
+ raise ValueError("workspace almanac_path must match root_path/almanac_root")
44
+ return self
45
+
46
+
47
+ class WorkspaceRegistryEntry(CodeAlmanacModel):
48
+ name: str
49
+ description: str = ""
50
+ path: Path
51
+ almanac_root: Path = Field(default=DEFAULT_ALMANAC_ROOT)
52
+ registered_at: datetime
53
+ workspace_id: str
54
+
55
+ @field_validator("name")
56
+ @classmethod
57
+ def require_name(cls, value: str) -> str:
58
+ return required_text(value, "workspace name")
59
+
60
+ @field_validator("almanac_root")
61
+ @classmethod
62
+ def validate_almanac_root(cls, value: Path) -> Path:
63
+ return validate_almanac_root_field(value)
64
+
65
+ def to_workspace(self) -> Workspace:
66
+ return Workspace(
67
+ workspace_id=self.workspace_id,
68
+ name=self.name,
69
+ description=self.description,
70
+ root_path=self.path,
71
+ almanac_root=self.almanac_root,
72
+ almanac_path=self.path / self.almanac_root,
73
+ registered_at=self.registered_at,
74
+ )
75
+
76
+
77
+ class WorkspaceRegistryStatus(StrEnum):
78
+ AVAILABLE = "available"
79
+ MISSING_REPO = "missing_repo"
80
+ MISSING_ALMANAC = "missing_almanac"
81
+
82
+
83
+ class WorkspaceListItem(CodeAlmanacModel):
84
+ workspace: Workspace
85
+ status: WorkspaceRegistryStatus
86
+
87
+
88
+ class WorkspaceListResult(CodeAlmanacModel):
89
+ items: tuple[WorkspaceListItem, ...]
90
+
91
+
92
+ class DropWorkspaceResult(CodeAlmanacModel):
93
+ dropped: tuple[Workspace, ...]
94
+
95
+
96
+ class WorkspacePathState(StrEnum):
97
+ ADDED = "added"
98
+ COPIED = "copied"
99
+ DELETED = "deleted"
100
+ MODIFIED = "modified"
101
+ RENAMED = "renamed"
102
+ TYPE_CHANGED = "type_changed"
103
+ UNMERGED = "unmerged"
104
+ UNTRACKED = "untracked"
105
+ UNKNOWN = "unknown"
106
+
107
+
108
+ class WorkspacePathChange(CodeAlmanacModel):
109
+ path: Path
110
+ state: WorkspacePathState
111
+ status: str
112
+ fingerprint: str | None = None
113
+
114
+ @field_validator("status")
115
+ @classmethod
116
+ def require_status(cls, value: str) -> str:
117
+ return required_text(value, "workspace path status")
118
+
119
+
120
+ class WorkspaceChangeSnapshot(CodeAlmanacModel):
121
+ root_path: Path
122
+ available: bool
123
+ changes: tuple[WorkspacePathChange, ...] = ()
124
+ unavailable_reason: str | None = None
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+ from typing import Protocol
3
+
4
+ from codealmanac.services.workspaces.models import WorkspaceChangeSnapshot
5
+
6
+
7
+ class WorkspaceChangeProbe(Protocol):
8
+ def snapshot(self, root_path: Path) -> WorkspaceChangeSnapshot:
9
+ """Return the current observable local change state for a workspace."""