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,283 @@
1
+ import platform
2
+ import sys
3
+ from collections.abc import Sequence
4
+
5
+ from codealmanac.core.errors import CodeAlmanacError, NotFoundError
6
+ from codealmanac.manual import ManualLibrary
7
+ from codealmanac.services.diagnostics.models import (
8
+ DoctorCheck,
9
+ DoctorReport,
10
+ DoctorStatus,
11
+ )
12
+ from codealmanac.services.diagnostics.requests import DoctorRequest
13
+ from codealmanac.services.index.models import HealthReport, IndexSummary
14
+ from codealmanac.services.index.service import IndexService
15
+ from codealmanac.services.workspaces.models import Workspace, WorkspaceRegistryStatus
16
+ from codealmanac.services.workspaces.requests import SelectWorkspaceRequest
17
+ from codealmanac.services.workspaces.service import (
18
+ WorkspacesService,
19
+ workspace_registry_status,
20
+ )
21
+
22
+
23
+ class DiagnosticsService:
24
+ def __init__(
25
+ self,
26
+ workspaces: WorkspacesService,
27
+ index: IndexService,
28
+ manual: ManualLibrary,
29
+ version: str,
30
+ python_version: str | None = None,
31
+ python_supported: bool | None = None,
32
+ ):
33
+ self.workspaces = workspaces
34
+ self.index = index
35
+ self.manual = manual
36
+ self.version = version
37
+ self.python_version = python_version or platform.python_version()
38
+ if python_supported is None:
39
+ self.python_supported = sys.version_info >= (3, 12)
40
+ else:
41
+ self.python_supported = python_supported
42
+
43
+ def check(self, request: DoctorRequest) -> DoctorReport:
44
+ return DoctorReport(
45
+ version=self.version,
46
+ install=self._install_checks(),
47
+ wiki=self._wiki_checks(request),
48
+ )
49
+
50
+ def _install_checks(self) -> tuple[DoctorCheck, ...]:
51
+ return (
52
+ DoctorCheck(
53
+ key="install.version",
54
+ status=DoctorStatus.OK,
55
+ message=f"codealmanac {self.version}",
56
+ ),
57
+ DoctorCheck(
58
+ key="install.python",
59
+ status=(
60
+ DoctorStatus.OK if self.python_supported else DoctorStatus.PROBLEM
61
+ ),
62
+ message=f"python {self.python_version}",
63
+ fix=None
64
+ if self.python_supported
65
+ else "install Python 3.12 or newer",
66
+ ),
67
+ DoctorCheck(
68
+ key="install.registry",
69
+ status=DoctorStatus.INFO,
70
+ message=f"registry: {self.workspaces.registry_path}",
71
+ ),
72
+ self._manual_package_check(),
73
+ )
74
+
75
+ def _wiki_checks(self, request: DoctorRequest) -> tuple[DoctorCheck, ...]:
76
+ workspace = self._select_workspace(request)
77
+ if isinstance(workspace, DoctorCheck):
78
+ return (workspace,)
79
+ registry_status = workspace_registry_status(workspace)
80
+ checks: list[DoctorCheck] = [
81
+ DoctorCheck(
82
+ key="wiki.repo",
83
+ status=DoctorStatus.INFO,
84
+ message=f"repo: {workspace.root_path}",
85
+ ),
86
+ registered_check(workspace, registry_status),
87
+ ]
88
+ if registry_status != WorkspaceRegistryStatus.AVAILABLE:
89
+ return tuple(checks)
90
+ checks.extend(self._index_checks(workspace))
91
+ checks.append(self._manual_workspace_check(workspace))
92
+ checks.append(self._health_check(workspace))
93
+ return tuple(checks)
94
+
95
+ def _manual_package_check(self) -> DoctorCheck:
96
+ try:
97
+ inventory = self.manual.inventory()
98
+ except CodeAlmanacError as error:
99
+ return DoctorCheck(
100
+ key="install.manual",
101
+ status=DoctorStatus.PROBLEM,
102
+ message=f"manual package unavailable: {first_line(str(error))}",
103
+ fix="reinstall codealmanac",
104
+ )
105
+ return DoctorCheck(
106
+ key="install.manual",
107
+ status=DoctorStatus.OK,
108
+ message=f"manual: {len(inventory.documents)} bundled docs",
109
+ )
110
+
111
+ def _manual_workspace_check(self, workspace: Workspace) -> DoctorCheck:
112
+ try:
113
+ status = self.manual.workspace_status(workspace.almanac_path / "manual")
114
+ except CodeAlmanacError as error:
115
+ return DoctorCheck(
116
+ key="wiki.manual",
117
+ status=DoctorStatus.PROBLEM,
118
+ message=f"manual unavailable: {first_line(str(error))}",
119
+ fix="run: codealmanac build",
120
+ )
121
+ if status.complete:
122
+ if len(status.changed) > 0:
123
+ changed = ", ".join(status.changed)
124
+ return DoctorCheck(
125
+ key="wiki.manual",
126
+ status=DoctorStatus.INFO,
127
+ message=f"manual differs: {changed}",
128
+ fix=(
129
+ "review local manual files; "
130
+ "codealmanac build preserves existing files"
131
+ ),
132
+ )
133
+ return DoctorCheck(
134
+ key="wiki.manual",
135
+ status=DoctorStatus.OK,
136
+ message=f"manual: {len(status.present)} docs",
137
+ )
138
+ missing = ", ".join(status.missing)
139
+ return DoctorCheck(
140
+ key="wiki.manual",
141
+ status=DoctorStatus.PROBLEM,
142
+ message=f"manual missing: {missing}",
143
+ fix="run: codealmanac build",
144
+ )
145
+
146
+ def _select_workspace(self, request: DoctorRequest) -> Workspace | DoctorCheck:
147
+ try:
148
+ if request.wiki is None:
149
+ return self.workspaces.resolve(request.cwd)
150
+ return self.workspaces.select(
151
+ SelectWorkspaceRequest(selector=request.wiki, base_path=request.cwd)
152
+ )
153
+ except NotFoundError as error:
154
+ if request.wiki is None:
155
+ return DoctorCheck(
156
+ key="wiki.none",
157
+ status=DoctorStatus.INFO,
158
+ message="no Almanac wiki found from current directory",
159
+ fix="run: codealmanac init",
160
+ )
161
+ return DoctorCheck(
162
+ key="wiki.selected",
163
+ status=DoctorStatus.PROBLEM,
164
+ message=first_line(str(error)),
165
+ fix="run: codealmanac list",
166
+ )
167
+ except CodeAlmanacError as error:
168
+ return DoctorCheck(
169
+ key="wiki.selected",
170
+ status=DoctorStatus.PROBLEM,
171
+ message=first_line(str(error)),
172
+ fix="run: codealmanac list",
173
+ )
174
+
175
+ def _index_checks(self, workspace: Workspace) -> Sequence[DoctorCheck]:
176
+ try:
177
+ summary = self.index.summary(workspace.workspace_id)
178
+ except Exception as error:
179
+ return (
180
+ DoctorCheck(
181
+ key="wiki.index",
182
+ status=DoctorStatus.PROBLEM,
183
+ message=f"could not rebuild index: {first_line(str(error))}",
184
+ fix="run: codealmanac reindex",
185
+ ),
186
+ )
187
+ return (
188
+ DoctorCheck(
189
+ key="wiki.index",
190
+ status=DoctorStatus.OK,
191
+ message=index_message(summary),
192
+ ),
193
+ )
194
+
195
+ def _health_check(self, workspace: Workspace) -> DoctorCheck:
196
+ try:
197
+ report = self.index.health_report(workspace.workspace_id)
198
+ except Exception as error:
199
+ return DoctorCheck(
200
+ key="wiki.health",
201
+ status=DoctorStatus.PROBLEM,
202
+ message=f"could not run health: {first_line(str(error))}",
203
+ fix="run: codealmanac health",
204
+ )
205
+ problems = health_problem_count(report)
206
+ if problems == 0:
207
+ return DoctorCheck(
208
+ key="wiki.health",
209
+ status=DoctorStatus.OK,
210
+ message="health: 0 problems",
211
+ )
212
+ return DoctorCheck(
213
+ key="wiki.health",
214
+ status=DoctorStatus.PROBLEM,
215
+ message=f"health: {problems} {problem_word(problems)}",
216
+ fix="run: codealmanac health",
217
+ )
218
+
219
+
220
+ def index_message(summary: IndexSummary) -> str:
221
+ skip_suffix = (
222
+ f", {summary.files_skipped} skipped" if summary.files_skipped > 0 else ""
223
+ )
224
+ return (
225
+ f"index: {summary.pages} {page_word(summary.pages)}, "
226
+ f"{summary.topics} {topic_word(summary.topics)} "
227
+ f"({summary.files_seen} files seen{skip_suffix})"
228
+ )
229
+
230
+
231
+ def health_problem_count(report: HealthReport) -> int:
232
+ return (
233
+ len(report.orphans)
234
+ + len(report.dead_refs)
235
+ + len(report.broken_links)
236
+ + len(report.broken_xwiki)
237
+ + len(report.empty_topics)
238
+ + len(report.empty_pages)
239
+ )
240
+
241
+
242
+ def first_line(value: str) -> str:
243
+ return value.splitlines()[0] if value.splitlines() else value
244
+
245
+
246
+ def registered_check(
247
+ workspace: Workspace,
248
+ status: WorkspaceRegistryStatus,
249
+ ) -> DoctorCheck:
250
+ registered = (
251
+ f"registered as '{workspace.name}' ({workspace.almanac_root.as_posix()})"
252
+ )
253
+ if status == WorkspaceRegistryStatus.AVAILABLE:
254
+ return DoctorCheck(
255
+ key="wiki.registered",
256
+ status=DoctorStatus.OK,
257
+ message=registered,
258
+ )
259
+ if status == WorkspaceRegistryStatus.MISSING_REPO:
260
+ return DoctorCheck(
261
+ key="wiki.registered",
262
+ status=DoctorStatus.PROBLEM,
263
+ message=f"{registered}, but repo path is missing",
264
+ fix=f"run: codealmanac list --drop {workspace.workspace_id}",
265
+ )
266
+ return DoctorCheck(
267
+ key="wiki.registered",
268
+ status=DoctorStatus.PROBLEM,
269
+ message=f"{registered}, but Almanac root is missing: {workspace.almanac_path}",
270
+ fix="run: codealmanac build",
271
+ )
272
+
273
+
274
+ def page_word(count: int) -> str:
275
+ return "page" if count == 1 else "pages"
276
+
277
+
278
+ def topic_word(count: int) -> str:
279
+ return "topic" if count == 1 else "topics"
280
+
281
+
282
+ def problem_word(count: int) -> str:
283
+ return "problem" if count == 1 else "problems"
@@ -0,0 +1 @@
1
+ """Normalized AI harness contracts for lifecycle workflows."""
@@ -0,0 +1,104 @@
1
+ from enum import StrEnum
2
+ from pathlib import Path
3
+
4
+ from pydantic import field_validator
5
+
6
+ from codealmanac.core.models import CodeAlmanacModel
7
+ from codealmanac.core.text import required_text
8
+
9
+
10
+ class HarnessKind(StrEnum):
11
+ CODEX = "codex"
12
+ CLAUDE = "claude"
13
+
14
+
15
+ class HarnessRunStatus(StrEnum):
16
+ SUCCEEDED = "succeeded"
17
+ FAILED = "failed"
18
+ CANCELLED = "cancelled"
19
+
20
+
21
+ class HarnessEventKind(StrEnum):
22
+ TEXT = "text"
23
+ TOOL_USE = "tool_use"
24
+ TOOL_RESULT = "tool_result"
25
+ TOOL_SUMMARY = "tool_summary"
26
+ CONTEXT_USAGE = "context_usage"
27
+ WARNING = "warning"
28
+ ERROR = "error"
29
+ DONE = "done"
30
+
31
+
32
+ class HarnessReadiness(CodeAlmanacModel):
33
+ kind: HarnessKind
34
+ available: bool
35
+ message: str
36
+
37
+ @field_validator("message")
38
+ @classmethod
39
+ def require_message(cls, value: str) -> str:
40
+ return required_text(value, "harness readiness message")
41
+
42
+
43
+ class HarnessTranscriptRef(CodeAlmanacModel):
44
+ kind: HarnessKind
45
+ session_id: str
46
+ transcript_path: Path | None = None
47
+
48
+ @field_validator("session_id")
49
+ @classmethod
50
+ def require_session_id(cls, value: str) -> str:
51
+ return required_text(value, "harness transcript session id")
52
+
53
+
54
+ class HarnessEvent(CodeAlmanacModel):
55
+ kind: HarnessEventKind
56
+ message: str
57
+ status: HarnessRunStatus | None = None
58
+
59
+ @field_validator("message")
60
+ @classmethod
61
+ def require_message(cls, value: str) -> str:
62
+ return required_text(value, "harness event message")
63
+
64
+
65
+ class HarnessRunResult(CodeAlmanacModel):
66
+ kind: HarnessKind
67
+ status: HarnessRunStatus
68
+ output_text: str
69
+ summary: str | None = None
70
+ changed_files: tuple[Path, ...] = ()
71
+ transcript: HarnessTranscriptRef | None = None
72
+ events: tuple[HarnessEvent, ...] = ()
73
+
74
+ @field_validator("output_text")
75
+ @classmethod
76
+ def require_output_text(cls, value: str) -> str:
77
+ return required_text(value, "harness output")
78
+
79
+
80
+ def terminal_harness_event(
81
+ kind: HarnessKind,
82
+ status: HarnessRunStatus,
83
+ output_text: str,
84
+ ) -> HarnessEvent:
85
+ return HarnessEvent(
86
+ kind=HarnessEventKind.DONE,
87
+ status=status,
88
+ message=terminal_harness_message(kind, status, output_text),
89
+ )
90
+
91
+
92
+ def terminal_harness_message(
93
+ kind: HarnessKind,
94
+ status: HarnessRunStatus,
95
+ output_text: str,
96
+ ) -> str:
97
+ suffix = first_line(output_text)
98
+ details = f": {suffix}" if suffix else ""
99
+ return f"{kind.value} {status.value}{details}"
100
+
101
+
102
+ def first_line(value: str) -> str:
103
+ lines = value.splitlines()
104
+ return lines[0] if lines else value
@@ -0,0 +1,18 @@
1
+ from typing import Protocol
2
+
3
+ from codealmanac.services.harnesses.models import (
4
+ HarnessKind,
5
+ HarnessReadiness,
6
+ HarnessRunResult,
7
+ )
8
+ from codealmanac.services.harnesses.requests import RunHarnessRequest
9
+
10
+
11
+ class HarnessAdapter(Protocol):
12
+ kind: HarnessKind
13
+
14
+ def check(self) -> HarnessReadiness:
15
+ """Return local readiness without starting an agent run."""
16
+
17
+ def run(self, request: RunHarnessRequest) -> HarnessRunResult:
18
+ """Run one normalized agent task."""
@@ -0,0 +1,19 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import field_validator
4
+
5
+ from codealmanac.core.models import CodeAlmanacModel
6
+ from codealmanac.core.text import required_text
7
+ from codealmanac.services.harnesses.models import HarnessKind
8
+
9
+
10
+ class RunHarnessRequest(CodeAlmanacModel):
11
+ kind: HarnessKind
12
+ cwd: Path
13
+ prompt: str
14
+ title: str | None = None
15
+
16
+ @field_validator("prompt")
17
+ @classmethod
18
+ def require_prompt(cls, value: str) -> str:
19
+ return required_text(value, "harness prompt")
@@ -0,0 +1,38 @@
1
+ from collections.abc import Sequence
2
+
3
+ from codealmanac.core.errors import ConflictError, NotFoundError
4
+ from codealmanac.services.harnesses.models import (
5
+ HarnessKind,
6
+ HarnessReadiness,
7
+ HarnessRunResult,
8
+ )
9
+ from codealmanac.services.harnesses.ports import HarnessAdapter
10
+ from codealmanac.services.harnesses.requests import RunHarnessRequest
11
+
12
+
13
+ class HarnessesService:
14
+ def __init__(self, adapters: Sequence[HarnessAdapter] = ()):
15
+ self.adapters = adapters_by_kind(adapters)
16
+
17
+ def check(self) -> tuple[HarnessReadiness, ...]:
18
+ return tuple(adapter.check() for adapter in self.adapters.values())
19
+
20
+ def run(self, request: RunHarnessRequest) -> HarnessRunResult:
21
+ return self.adapter_for(request.kind).run(request)
22
+
23
+ def adapter_for(self, kind: HarnessKind) -> HarnessAdapter:
24
+ try:
25
+ return self.adapters[kind]
26
+ except KeyError as error:
27
+ raise NotFoundError("harness", kind.value) from error
28
+
29
+
30
+ def adapters_by_kind(
31
+ adapters: Sequence[HarnessAdapter],
32
+ ) -> dict[HarnessKind, HarnessAdapter]:
33
+ indexed: dict[HarnessKind, HarnessAdapter] = {}
34
+ for adapter in adapters:
35
+ if adapter.kind in indexed:
36
+ raise ConflictError(f"duplicate harness adapter: {adapter.kind.value}")
37
+ indexed[adapter.kind] = adapter
38
+ return indexed
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+
3
+ from codealmanac.core.models import CodeAlmanacModel
4
+
5
+
6
+ class HealthCheckRequest(CodeAlmanacModel):
7
+ cwd: Path
8
+ wiki: str | None = None
@@ -0,0 +1,20 @@
1
+ from codealmanac.services.health.requests import HealthCheckRequest
2
+ from codealmanac.services.index.models import HealthReport
3
+ from codealmanac.services.index.service import IndexService
4
+ from codealmanac.services.workspaces.requests import SelectWorkspaceRequest
5
+ from codealmanac.services.workspaces.service import WorkspacesService
6
+
7
+
8
+ class HealthService:
9
+ def __init__(self, workspaces: WorkspacesService, index: IndexService):
10
+ self.workspaces = workspaces
11
+ self.index = index
12
+
13
+ def check(self, request: HealthCheckRequest) -> HealthReport:
14
+ if request.wiki is None:
15
+ workspace = self.workspaces.resolve(request.cwd)
16
+ else:
17
+ workspace = self.workspaces.select(
18
+ SelectWorkspaceRequest(selector=request.wiki, base_path=request.cwd)
19
+ )
20
+ return self.index.health_report(workspace.workspace_id)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,135 @@
1
+ from enum import StrEnum
2
+ from pathlib import Path
3
+
4
+ from codealmanac.core.models import CodeAlmanacModel
5
+
6
+
7
+ class HealthCategory(StrEnum):
8
+ ORPHANS = "orphans"
9
+ DEAD_REFS = "dead_refs"
10
+ BROKEN_LINKS = "broken_links"
11
+ BROKEN_XWIKI = "broken_xwiki"
12
+ EMPTY_TOPICS = "empty_topics"
13
+ EMPTY_PAGES = "empty_pages"
14
+
15
+
16
+ class SearchPageResult(CodeAlmanacModel):
17
+ slug: str
18
+ title: str | None
19
+ summary: str | None
20
+ updated_at: int
21
+ archived_at: int | None
22
+ superseded_by: str | None
23
+ topics: tuple[str, ...]
24
+
25
+
26
+ class PageFileReference(CodeAlmanacModel):
27
+ path: str
28
+ is_dir: bool
29
+
30
+
31
+ class CrossWikiReference(CodeAlmanacModel):
32
+ wiki: str
33
+ target: str
34
+
35
+
36
+ class PageView(CodeAlmanacModel):
37
+ slug: str
38
+ title: str | None
39
+ summary: str | None
40
+ file_path: Path
41
+ updated_at: int
42
+ archived_at: int | None
43
+ superseded_by: str | None
44
+ topics: tuple[str, ...]
45
+ file_refs: tuple[PageFileReference, ...]
46
+ wikilinks_out: tuple[str, ...]
47
+ wikilinks_in: tuple[str, ...]
48
+ cross_wiki_links: tuple[CrossWikiReference, ...]
49
+ body: str
50
+
51
+
52
+ class IndexRefreshResult(CodeAlmanacModel):
53
+ changed: int
54
+ removed: int
55
+ pages_indexed: int
56
+ files_seen: int
57
+ files_skipped: int
58
+
59
+
60
+ class IndexCounts(CodeAlmanacModel):
61
+ pages: int
62
+ topics: int
63
+
64
+
65
+ class IndexSummary(CodeAlmanacModel):
66
+ pages: int
67
+ topics: int
68
+ files_seen: int
69
+ files_skipped: int
70
+
71
+
72
+ class IndexedPageFingerprint(CodeAlmanacModel):
73
+ slug: str
74
+ relative_path: str
75
+ content_hash: str
76
+
77
+
78
+ class IndexSourceSignature(CodeAlmanacModel):
79
+ pages: tuple[IndexedPageFingerprint, ...]
80
+ topics_hash: str
81
+ files_seen: int
82
+ files_skipped: int
83
+
84
+
85
+ class TopicSummary(CodeAlmanacModel):
86
+ slug: str
87
+ title: str | None
88
+ description: str | None
89
+ page_count: int
90
+
91
+
92
+ class TopicDetail(CodeAlmanacModel):
93
+ slug: str
94
+ title: str | None
95
+ description: str | None
96
+ parents: tuple[str, ...]
97
+ children: tuple[str, ...]
98
+ pages: tuple[str, ...]
99
+
100
+
101
+ class OrphanPage(CodeAlmanacModel):
102
+ slug: str
103
+
104
+
105
+ class DeadFileReference(CodeAlmanacModel):
106
+ slug: str
107
+ path: str
108
+
109
+
110
+ class BrokenPageLink(CodeAlmanacModel):
111
+ source_slug: str
112
+ target_slug: str
113
+
114
+
115
+ class BrokenCrossWikiLink(CodeAlmanacModel):
116
+ source_slug: str
117
+ target_wiki: str
118
+ target_slug: str
119
+
120
+
121
+ class EmptyTopic(CodeAlmanacModel):
122
+ slug: str
123
+
124
+
125
+ class EmptyPage(CodeAlmanacModel):
126
+ slug: str
127
+
128
+
129
+ class HealthReport(CodeAlmanacModel):
130
+ orphans: tuple[OrphanPage, ...]
131
+ dead_refs: tuple[DeadFileReference, ...]
132
+ broken_links: tuple[BrokenPageLink, ...]
133
+ broken_xwiki: tuple[BrokenCrossWikiLink, ...]
134
+ empty_topics: tuple[EmptyTopic, ...]
135
+ empty_pages: tuple[EmptyPage, ...]
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import field_validator
4
+
5
+ from codealmanac.core.models import CodeAlmanacModel
6
+
7
+
8
+ class SearchIndexRequest(CodeAlmanacModel):
9
+ query: str | None = None
10
+ topics: tuple[str, ...] = ()
11
+ mentions: str | None = None
12
+ include_archive: bool = False
13
+ archived: bool = False
14
+ limit: int | None = None
15
+
16
+ @field_validator("limit")
17
+ @classmethod
18
+ def non_negative_limit(cls, value: int | None) -> int | None:
19
+ if value is not None and value < 0:
20
+ raise ValueError("limit must be non-negative")
21
+ return value
22
+
23
+
24
+ class ReindexRequest(CodeAlmanacModel):
25
+ cwd: Path
26
+ wiki: str | None = None