alku 0.5.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.
- alku/__init__.py +3 -0
- alku/__main__.py +4 -0
- alku/agent_configs.py +269 -0
- alku/claude.py +144 -0
- alku/cli.py +35 -0
- alku/codebase_memory.py +51 -0
- alku/commands/__init__.py +1 -0
- alku/commands/common.py +144 -0
- alku/commands/docs.py +244 -0
- alku/commands/init.py +67 -0
- alku/commands/self.py +45 -0
- alku/file_transactions.py +484 -0
- alku/gate.py +230 -0
- alku/generation/__init__.py +5 -0
- alku/generation/diagnostics.py +252 -0
- alku/generation/orchestration.py +396 -0
- alku/generation/rendering.py +241 -0
- alku/generation/transactions.py +588 -0
- alku/markdown.py +208 -0
- alku/migrations/__init__.py +26 -0
- alku/migrations/base.py +58 -0
- alku/migrations/registry.py +7 -0
- alku/migrations/runner.py +103 -0
- alku/model_config.py +212 -0
- alku/models.py +125 -0
- alku/models.yml +28 -0
- alku/paths.py +102 -0
- alku/pipeline/__init__.py +25 -0
- alku/pipeline/base.py +97 -0
- alku/pipeline/context.py +162 -0
- alku/pipeline/runner.py +136 -0
- alku/pipeline/steps.py +396 -0
- alku/proposal/__init__.py +1 -0
- alku/proposal/identifiers.py +96 -0
- alku/proposal/planning.py +78 -0
- alku/proposal/rewriting.py +414 -0
- alku/proposal/transaction.py +231 -0
- alku/proposal/types.py +65 -0
- alku/providers.py +147 -0
- alku/queries.py +257 -0
- alku/repository.py +345 -0
- alku/skill_catalog.py +154 -0
- alku/skill_planning.py +203 -0
- alku/skill_sync.py +134 -0
- alku/skill_types.py +37 -0
- alku/skill_writes.py +262 -0
- alku/skills/alku-architecture/SKILL.md +70 -0
- alku/skills/alku-auto/SKILL.md +58 -0
- alku/skills/alku-brainstorm/SKILL.md +24 -0
- alku/skills/alku-docs/SKILL.md +20 -0
- alku/skills/alku-workflow/SKILL.md +57 -0
- alku/skills/alku-workflow/references/develop.md +15 -0
- alku/skills/alku-workflow/references/research.md +15 -0
- alku/skills/alku-workflow/references/review.md +20 -0
- alku/structure.py +150 -0
- alku/templates/README.md +76 -0
- alku/templates/gate.md +8 -0
- alku/tooling.py +425 -0
- alku/updates.py +244 -0
- alku/validation.py +559 -0
- alku-0.5.0.dist-info/METADATA +92 -0
- alku-0.5.0.dist-info/RECORD +65 -0
- alku-0.5.0.dist-info/WHEEL +4 -0
- alku-0.5.0.dist-info/entry_points.txt +2 -0
- alku-0.5.0.dist-info/licenses/LICENSE +21 -0
alku/__init__.py
ADDED
alku/__main__.py
ADDED
alku/agent_configs.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Managed project-scoped agent profiles for supported model providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .providers import profile_for
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AgentConfigSyncError(ValueError):
|
|
13
|
+
"""A managed agent profile cannot be synchronized safely."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class AgentSpec:
|
|
18
|
+
name: str
|
|
19
|
+
description: str
|
|
20
|
+
model: str
|
|
21
|
+
effort: str
|
|
22
|
+
instructions: str
|
|
23
|
+
read_only: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AgentConfigSyncReport:
|
|
28
|
+
state: str
|
|
29
|
+
copied: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _agent_specs(provider: str) -> tuple[AgentSpec, ...]:
|
|
33
|
+
"""Build native agent specs from the packaged model matrix."""
|
|
34
|
+
profile = profile_for(provider)
|
|
35
|
+
if provider == "openai":
|
|
36
|
+
workflow_instructions = (
|
|
37
|
+
"Read and follow .agents/skills/alku-workflow/SKILL.md. Own one bounded "
|
|
38
|
+
"feature, retain architecture and documentation context, and return a "
|
|
39
|
+
"verified result to the parent agent."
|
|
40
|
+
)
|
|
41
|
+
research_instructions = (
|
|
42
|
+
"Read and follow .agents/skills/alku-workflow/references/research.md. "
|
|
43
|
+
"Stay within the delegated question and return concise, cited evidence."
|
|
44
|
+
)
|
|
45
|
+
review_instructions = (
|
|
46
|
+
"Read and follow .agents/skills/alku-workflow/references/review.md. "
|
|
47
|
+
"Review the post-generation artifact independently and lead with concrete "
|
|
48
|
+
"findings."
|
|
49
|
+
)
|
|
50
|
+
develop_instructions = (
|
|
51
|
+
"Read and follow .agents/skills/alku-workflow/references/develop.md. "
|
|
52
|
+
"Implement only the assigned files and acceptance criteria, preserve "
|
|
53
|
+
"unrelated changes, and report targeted verification."
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
workflow_instructions = (
|
|
57
|
+
"Follow the preloaded alku-workflow skill. Own one bounded feature, retain "
|
|
58
|
+
"architecture and documentation context, and return a verified result to "
|
|
59
|
+
"the parent agent."
|
|
60
|
+
)
|
|
61
|
+
research_instructions = (
|
|
62
|
+
"Read .claude/skills/alku-workflow/references/research.md. Stay within the "
|
|
63
|
+
"delegated question and return concise, cited evidence."
|
|
64
|
+
)
|
|
65
|
+
review_instructions = (
|
|
66
|
+
"Read .claude/skills/alku-workflow/references/review.md. Review the "
|
|
67
|
+
"post-generation artifact independently and lead with concrete findings."
|
|
68
|
+
)
|
|
69
|
+
develop_instructions = (
|
|
70
|
+
"Read .claude/skills/alku-workflow/references/develop.md. Implement only "
|
|
71
|
+
"the assigned files and acceptance criteria, preserve unrelated changes, "
|
|
72
|
+
"and report targeted verification."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
specs = [
|
|
76
|
+
AgentSpec(
|
|
77
|
+
"alku-workflow",
|
|
78
|
+
"Own one bounded Alku feature from approved plan through verification.",
|
|
79
|
+
profile.model_for("workflow"),
|
|
80
|
+
profile.effort_for("workflow"),
|
|
81
|
+
workflow_instructions,
|
|
82
|
+
),
|
|
83
|
+
AgentSpec(
|
|
84
|
+
"alku-research",
|
|
85
|
+
"Investigate a bounded question and return evidence without editing code.",
|
|
86
|
+
profile.model_for("research"),
|
|
87
|
+
profile.effort_for("research"),
|
|
88
|
+
research_instructions,
|
|
89
|
+
True,
|
|
90
|
+
),
|
|
91
|
+
AgentSpec(
|
|
92
|
+
"alku-review",
|
|
93
|
+
"Independently review a completed Alku change for material risks.",
|
|
94
|
+
profile.model_for("review"),
|
|
95
|
+
profile.effort_for("review"),
|
|
96
|
+
review_instructions,
|
|
97
|
+
True,
|
|
98
|
+
),
|
|
99
|
+
]
|
|
100
|
+
specs.extend(
|
|
101
|
+
AgentSpec(
|
|
102
|
+
f"alku-develop-{effort}",
|
|
103
|
+
f"Implement bounded Alku work at {effort} reasoning effort.",
|
|
104
|
+
profile.model_for(f"develop-{effort}"),
|
|
105
|
+
profile.effort_for(f"develop-{effort}"),
|
|
106
|
+
develop_instructions,
|
|
107
|
+
)
|
|
108
|
+
for effort in ("low", "medium", "high")
|
|
109
|
+
)
|
|
110
|
+
return tuple(specs)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
OPENAI_AGENT_SPECS = _agent_specs("openai")
|
|
114
|
+
ANTHROPIC_AGENT_SPECS = _agent_specs("anthropic")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _kind(path: Path) -> str:
|
|
118
|
+
try:
|
|
119
|
+
mode = path.lstat().st_mode
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
return "missing"
|
|
122
|
+
except OSError:
|
|
123
|
+
return "other"
|
|
124
|
+
if stat.S_ISLNK(mode):
|
|
125
|
+
return "symlink"
|
|
126
|
+
if stat.S_ISREG(mode):
|
|
127
|
+
return "file"
|
|
128
|
+
if stat.S_ISDIR(mode):
|
|
129
|
+
return "directory"
|
|
130
|
+
return "other"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _safe_parent(root: Path, path: Path) -> bool:
|
|
134
|
+
try:
|
|
135
|
+
relative = path.parent.relative_to(root)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return False
|
|
138
|
+
current = root
|
|
139
|
+
for part in relative.parts:
|
|
140
|
+
current /= part
|
|
141
|
+
if _kind(current) in {"symlink", "file", "other"}:
|
|
142
|
+
return False
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _toml(spec: AgentSpec) -> str:
|
|
147
|
+
sandbox = 'sandbox_mode = "read-only"\n' if spec.read_only else ""
|
|
148
|
+
return (
|
|
149
|
+
"# alku:managed-agent\n"
|
|
150
|
+
f'name = "{spec.name}"\n'
|
|
151
|
+
f'description = "{spec.description}"\n'
|
|
152
|
+
f'model = "{spec.model}"\n'
|
|
153
|
+
f'model_reasoning_effort = "{spec.effort}"\n'
|
|
154
|
+
f"{sandbox}"
|
|
155
|
+
'developer_instructions = """\n'
|
|
156
|
+
f"{spec.instructions}\n"
|
|
157
|
+
'"""\n'
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _markdown(spec: AgentSpec) -> str:
|
|
162
|
+
permission = "\npermissionMode: plan" if spec.read_only else ""
|
|
163
|
+
skills = "\nskills:\n - alku-workflow" if spec.name == "alku-workflow" else ""
|
|
164
|
+
return (
|
|
165
|
+
"---\n"
|
|
166
|
+
f"name: {spec.name}\n"
|
|
167
|
+
f"description: {spec.description}\n"
|
|
168
|
+
f"model: {spec.model}\n"
|
|
169
|
+
f"effort: {spec.effort}"
|
|
170
|
+
f"{permission}{skills}\n"
|
|
171
|
+
"---\n"
|
|
172
|
+
"<!-- alku:managed-agent -->\n\n"
|
|
173
|
+
f"{spec.instructions}\n"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def agent_config_outputs(root: Path) -> tuple[tuple[Path, str], ...]:
|
|
178
|
+
"""Return every provider-native project agent profile."""
|
|
179
|
+
outputs = [
|
|
180
|
+
(root / ".codex" / "agents" / f"{spec.name}.toml", _toml(spec))
|
|
181
|
+
for spec in OPENAI_AGENT_SPECS
|
|
182
|
+
]
|
|
183
|
+
outputs.extend(
|
|
184
|
+
(root / ".claude" / "agents" / f"{spec.name}.md", _markdown(spec))
|
|
185
|
+
for spec in ANTHROPIC_AGENT_SPECS
|
|
186
|
+
)
|
|
187
|
+
return tuple(outputs)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _managed(path: Path, content: str) -> bool:
|
|
191
|
+
if path.suffix == ".toml":
|
|
192
|
+
return content.startswith("# alku:managed-agent\n")
|
|
193
|
+
if not content.startswith("---\n"):
|
|
194
|
+
return False
|
|
195
|
+
end = content.find("\n---\n", 4)
|
|
196
|
+
return end >= 0 and content[end + 5 :].startswith("<!-- alku:managed-agent -->\n")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def sync_agent_configs_report(
|
|
200
|
+
root: Path, *, dry_run: bool = False
|
|
201
|
+
) -> AgentConfigSyncReport:
|
|
202
|
+
"""Synchronize both provider profile sets with whole-file ownership checks."""
|
|
203
|
+
planned: list[tuple[Path, str, str | None]] = []
|
|
204
|
+
for path, content in agent_config_outputs(root):
|
|
205
|
+
if not _safe_parent(root, path):
|
|
206
|
+
raise AgentConfigSyncError(f"unsafe managed agent parent: {path.parent}")
|
|
207
|
+
kind = _kind(path)
|
|
208
|
+
if kind == "missing":
|
|
209
|
+
planned.append((path, content, None))
|
|
210
|
+
continue
|
|
211
|
+
if kind != "file":
|
|
212
|
+
raise AgentConfigSyncError(f"unsafe managed agent collision: {path}")
|
|
213
|
+
try:
|
|
214
|
+
actual = path.read_text(encoding="utf-8")
|
|
215
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
216
|
+
raise AgentConfigSyncError(
|
|
217
|
+
f"cannot read managed agent {path}: {error}"
|
|
218
|
+
) from error
|
|
219
|
+
if actual == content:
|
|
220
|
+
continue
|
|
221
|
+
if not _managed(path, actual):
|
|
222
|
+
raise AgentConfigSyncError(
|
|
223
|
+
f"fail-closed agent collision: unmarked/user-owned file: {path}"
|
|
224
|
+
)
|
|
225
|
+
planned.append((path, content, actual))
|
|
226
|
+
if not dry_run:
|
|
227
|
+
for path, content, original in planned:
|
|
228
|
+
if not _safe_parent(root, path):
|
|
229
|
+
raise AgentConfigSyncError(
|
|
230
|
+
f"concurrent managed agent parent update: {path.parent}"
|
|
231
|
+
)
|
|
232
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
if original is None:
|
|
234
|
+
if _kind(path) != "missing":
|
|
235
|
+
raise AgentConfigSyncError(
|
|
236
|
+
f"concurrent managed agent update: {path}"
|
|
237
|
+
)
|
|
238
|
+
with path.open("x", encoding="utf-8", newline="") as stream:
|
|
239
|
+
stream.write(content)
|
|
240
|
+
continue
|
|
241
|
+
if _kind(path) != "file":
|
|
242
|
+
raise AgentConfigSyncError(f"concurrent managed agent update: {path}")
|
|
243
|
+
try:
|
|
244
|
+
current = path.read_text(encoding="utf-8")
|
|
245
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
246
|
+
raise AgentConfigSyncError(
|
|
247
|
+
f"cannot recheck managed agent {path}: {error}"
|
|
248
|
+
) from error
|
|
249
|
+
if current != original or not _managed(path, current):
|
|
250
|
+
raise AgentConfigSyncError(f"concurrent managed agent update: {path}")
|
|
251
|
+
temporary = path.with_name(path.name + ".alku-new")
|
|
252
|
+
if _kind(temporary) == "missing":
|
|
253
|
+
with temporary.open("x", encoding="utf-8", newline="") as stream:
|
|
254
|
+
stream.write(content)
|
|
255
|
+
elif (
|
|
256
|
+
_kind(temporary) != "file"
|
|
257
|
+
or temporary.read_text(encoding="utf-8") != content
|
|
258
|
+
):
|
|
259
|
+
raise AgentConfigSyncError(
|
|
260
|
+
f"unsafe managed agent staging collision: {temporary}"
|
|
261
|
+
)
|
|
262
|
+
if _kind(path) != "file" or path.read_text(encoding="utf-8") != original:
|
|
263
|
+
temporary.unlink(missing_ok=True)
|
|
264
|
+
raise AgentConfigSyncError(f"concurrent managed agent update: {path}")
|
|
265
|
+
temporary.replace(path)
|
|
266
|
+
return AgentConfigSyncReport(
|
|
267
|
+
"copied" if planned else "unchanged",
|
|
268
|
+
tuple(str(path.relative_to(root)) for path, _, _ in planned),
|
|
269
|
+
)
|
alku/claude.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Canonical Claude project-instruction import handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import stat
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .gate import BEGIN, END, _models_for_text, _provider_for_text, gate_template
|
|
10
|
+
|
|
11
|
+
CLAUDE_IMPORT = "@AGENTS.md\n"
|
|
12
|
+
_IMPORT = re.compile(r"(?<!\S)@AGENTS\.md(?!\S)")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ClaudeReferenceError(ValueError):
|
|
16
|
+
"""CLAUDE.md cannot be converted without risking authored content."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _without_managed_gate(content: str) -> str:
|
|
20
|
+
starts, ends = content.count(BEGIN), content.count(END)
|
|
21
|
+
if not starts and not ends:
|
|
22
|
+
return content
|
|
23
|
+
if starts != 1 or ends != 1 or content.index(BEGIN) > content.index(END):
|
|
24
|
+
raise ClaudeReferenceError("CLAUDE.md contains malformed alku markers")
|
|
25
|
+
start, end = content.index(BEGIN), content.index(END) + len(END)
|
|
26
|
+
block = content[start:end]
|
|
27
|
+
try:
|
|
28
|
+
provider = _provider_for_text(block)
|
|
29
|
+
except ValueError as error:
|
|
30
|
+
raise ClaudeReferenceError(str(error)) from error
|
|
31
|
+
if provider is None:
|
|
32
|
+
inferred = [
|
|
33
|
+
name
|
|
34
|
+
for name, skill_root in (("openai", ".agents"), ("anthropic", ".claude"))
|
|
35
|
+
if f"{skill_root}/skills/alku-workflow/SKILL.md" in block
|
|
36
|
+
]
|
|
37
|
+
provider = inferred[0] if len(inferred) == 1 else None
|
|
38
|
+
historical = (
|
|
39
|
+
f"{BEGIN}\n"
|
|
40
|
+
f"<!-- alku:provider {provider} -->\n"
|
|
41
|
+
"Use the `alku-workflow` skill.\n"
|
|
42
|
+
f"{END}"
|
|
43
|
+
)
|
|
44
|
+
if provider is not None and block.replace("\r\n", "\n") == historical:
|
|
45
|
+
return content[:start] + content[end:]
|
|
46
|
+
without_provider_marker = re.sub(
|
|
47
|
+
r"<!-- alku:provider(?: (?:openai|anthropic))? -->\r?\n?",
|
|
48
|
+
"",
|
|
49
|
+
block,
|
|
50
|
+
count=1,
|
|
51
|
+
)
|
|
52
|
+
if provider is not None and without_provider_marker.replace(
|
|
53
|
+
"\r\n", "\n"
|
|
54
|
+
) == gate_template(provider=provider).rstrip("\n"):
|
|
55
|
+
return content[:start] + content[end:]
|
|
56
|
+
models = _models_for_text(block)
|
|
57
|
+
skill_root = ".agents" if provider == "openai" else ".claude"
|
|
58
|
+
required = (
|
|
59
|
+
"## Alku workflow",
|
|
60
|
+
f"{skill_root}/skills/alku-workflow/SKILL.md",
|
|
61
|
+
)
|
|
62
|
+
if (
|
|
63
|
+
provider is None
|
|
64
|
+
or models is None
|
|
65
|
+
or any(item not in block for item in required)
|
|
66
|
+
or (
|
|
67
|
+
"Before work, run `alku self check`" not in block
|
|
68
|
+
and "Load the repository workflow skill" not in block
|
|
69
|
+
)
|
|
70
|
+
):
|
|
71
|
+
raise ClaudeReferenceError(
|
|
72
|
+
"CLAUDE.md marker block is not a recognized legacy Alku gate"
|
|
73
|
+
)
|
|
74
|
+
return content[:start] + content[end:]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def claude_reference_content(content: str | None) -> str:
|
|
78
|
+
"""Return CLAUDE.md with the legacy gate removed and AGENTS.md imported."""
|
|
79
|
+
exterior = _without_managed_gate(content or "")
|
|
80
|
+
if not exterior.strip():
|
|
81
|
+
return CLAUDE_IMPORT
|
|
82
|
+
if _IMPORT.search(exterior):
|
|
83
|
+
return exterior
|
|
84
|
+
separator = (
|
|
85
|
+
""
|
|
86
|
+
if exterior.endswith("\n\n")
|
|
87
|
+
else ("\n" if exterior.endswith("\n") else "\n\n")
|
|
88
|
+
)
|
|
89
|
+
return exterior + separator + CLAUDE_IMPORT
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def claude_reference_diagnostics(path: Path) -> list[dict[str, str]]:
|
|
93
|
+
"""Validate that CLAUDE.md imports AGENTS.md without a legacy Alku gate."""
|
|
94
|
+
try:
|
|
95
|
+
mode = path.lstat().st_mode
|
|
96
|
+
except FileNotFoundError:
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
"code": "missing_claude_reference",
|
|
100
|
+
"message": "CLAUDE.md must import AGENTS.md; run alku init",
|
|
101
|
+
"path": path.name,
|
|
102
|
+
"level": "error",
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
except OSError as error:
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
"code": "claude_reference_error",
|
|
109
|
+
"message": str(error),
|
|
110
|
+
"path": path.name,
|
|
111
|
+
"level": "error",
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
if stat.S_ISLNK(mode) or not stat.S_ISREG(mode):
|
|
115
|
+
return [
|
|
116
|
+
{
|
|
117
|
+
"code": "claude_reference_error",
|
|
118
|
+
"message": "CLAUDE.md must be a regular file",
|
|
119
|
+
"path": path.name,
|
|
120
|
+
"level": "error",
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
try:
|
|
124
|
+
content = path.read_text(encoding="utf-8")
|
|
125
|
+
expected = claude_reference_content(content)
|
|
126
|
+
except (OSError, UnicodeDecodeError, ClaudeReferenceError) as error:
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
"code": "claude_reference_error",
|
|
130
|
+
"message": str(error),
|
|
131
|
+
"path": path.name,
|
|
132
|
+
"level": "error",
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
if content == expected:
|
|
136
|
+
return []
|
|
137
|
+
return [
|
|
138
|
+
{
|
|
139
|
+
"code": "stale_claude_reference",
|
|
140
|
+
"message": "CLAUDE.md must import AGENTS.md; run alku init",
|
|
141
|
+
"path": path.name,
|
|
142
|
+
"level": "error",
|
|
143
|
+
}
|
|
144
|
+
]
|
alku/cli.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Top-level command assembly for Alku."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .commands.docs import docs
|
|
11
|
+
from .commands.init import init_command
|
|
12
|
+
from .commands.self import self_group
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
16
|
+
@click.version_option(version=__version__, prog_name="alku")
|
|
17
|
+
@click.option(
|
|
18
|
+
"--root",
|
|
19
|
+
type=click.Path(path_type=Path),
|
|
20
|
+
default=None,
|
|
21
|
+
help="repository root (default: current working directory)",
|
|
22
|
+
)
|
|
23
|
+
@click.option(
|
|
24
|
+
"--json", "as_json", is_flag=True, default=False, help="emit structured JSON only"
|
|
25
|
+
)
|
|
26
|
+
@click.pass_context
|
|
27
|
+
def cli(ctx: click.Context, root: Path | None, as_json: bool) -> None:
|
|
28
|
+
"""Initialize projects, maintain documentation, and manage Alku itself."""
|
|
29
|
+
ctx.ensure_object(dict)
|
|
30
|
+
ctx.obj.update(root=root if root is not None else Path.cwd(), json=as_json)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
cli.add_command(init_command)
|
|
34
|
+
cli.add_command(docs)
|
|
35
|
+
cli.add_command(self_group)
|
alku/codebase_memory.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Best-effort codebase-memory installation and version discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def codebase_memory_status(
|
|
12
|
+
*,
|
|
13
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
14
|
+
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
15
|
+
) -> dict[str, object]:
|
|
16
|
+
"""Detect codebase-memory-mcp and return a non-mutating recommendation."""
|
|
17
|
+
executable = which("codebase-memory-mcp")
|
|
18
|
+
if executable is None:
|
|
19
|
+
command = (
|
|
20
|
+
"Invoke-WebRequest -Uri https://raw.githubusercontent.com/DeusData/"
|
|
21
|
+
"codebase-memory-mcp/main/install.ps1 -OutFile install.ps1; "
|
|
22
|
+
".\\install.ps1"
|
|
23
|
+
if os.name == "nt"
|
|
24
|
+
else "curl -fsSL https://raw.githubusercontent.com/DeusData/"
|
|
25
|
+
"codebase-memory-mcp/main/install.sh | bash"
|
|
26
|
+
)
|
|
27
|
+
return {
|
|
28
|
+
"installed": False,
|
|
29
|
+
"version": None,
|
|
30
|
+
"recommendation": "Install codebase-memory-mcp for structural code discovery.",
|
|
31
|
+
"command": command,
|
|
32
|
+
}
|
|
33
|
+
version = None
|
|
34
|
+
try:
|
|
35
|
+
completed = runner(
|
|
36
|
+
[executable, "--version"],
|
|
37
|
+
text=True,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
timeout=5,
|
|
40
|
+
check=False,
|
|
41
|
+
)
|
|
42
|
+
if completed.returncode == 0:
|
|
43
|
+
version = (completed.stdout or completed.stderr).strip() or None
|
|
44
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
45
|
+
pass
|
|
46
|
+
return {
|
|
47
|
+
"installed": True,
|
|
48
|
+
"version": version,
|
|
49
|
+
"recommendation": "Keep codebase-memory-mcp current before large code changes.",
|
|
50
|
+
"command": "codebase-memory-mcp update",
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Click command groups exposed by :mod:`alku.cli`."""
|
alku/commands/common.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Shared Click options, output rendering, and repository resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from ..repository import scan_features
|
|
13
|
+
from ..validation import validate_query_metadata
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def common_options(
|
|
17
|
+
query: bool = False,
|
|
18
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
19
|
+
def decorate(command: Callable[..., Any]) -> Callable[..., Any]:
|
|
20
|
+
command = click.option(
|
|
21
|
+
"--root",
|
|
22
|
+
type=click.Path(path_type=Path),
|
|
23
|
+
default=None,
|
|
24
|
+
help="repository root; may also precede the command",
|
|
25
|
+
)(command)
|
|
26
|
+
command = click.option(
|
|
27
|
+
"--json",
|
|
28
|
+
"as_json",
|
|
29
|
+
is_flag=True,
|
|
30
|
+
default=False,
|
|
31
|
+
help="emit structured JSON only",
|
|
32
|
+
)(command)
|
|
33
|
+
if query:
|
|
34
|
+
command = click.option(
|
|
35
|
+
"--offset", type=click.IntRange(min=0), default=0, show_default=True
|
|
36
|
+
)(command)
|
|
37
|
+
command = click.option(
|
|
38
|
+
"--limit", type=click.IntRange(min=1), default=20, show_default=True
|
|
39
|
+
)(command)
|
|
40
|
+
return command
|
|
41
|
+
|
|
42
|
+
return decorate
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def filters(command: Callable[..., Any]) -> Callable[..., Any]:
|
|
46
|
+
for option in ("work", "delivery", "decision", "area"):
|
|
47
|
+
command = click.option(f"--{option}")(command)
|
|
48
|
+
return click.option("--depends-on")(command)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def emit(value: object, as_json: bool) -> None:
|
|
52
|
+
if as_json:
|
|
53
|
+
click.echo(json.dumps(value, indent=2, sort_keys=True))
|
|
54
|
+
elif isinstance(value, dict) and value.get("command") == "init":
|
|
55
|
+
steps = value.get("steps")
|
|
56
|
+
counts: dict[str, int] = {}
|
|
57
|
+
if isinstance(steps, list):
|
|
58
|
+
for step in steps:
|
|
59
|
+
if not isinstance(step, dict) or not isinstance(
|
|
60
|
+
step.get("status"), str
|
|
61
|
+
):
|
|
62
|
+
continue
|
|
63
|
+
status = str(step["status"])
|
|
64
|
+
counts[status] = counts.get(status, 0) + 1
|
|
65
|
+
summary = ", ".join(
|
|
66
|
+
f"{counts[status]} {status}"
|
|
67
|
+
for status in ("done", "skipped", "blocked", "error")
|
|
68
|
+
if counts.get(status)
|
|
69
|
+
)
|
|
70
|
+
glyph = "✓" if value.get("ok") else "×"
|
|
71
|
+
label = "Initialized" if value.get("ok") else "Initialization failed"
|
|
72
|
+
click.echo(f"\n{glyph} {label}" + (f" · {summary}" if summary else ""))
|
|
73
|
+
recommendations = value.get("recommendations")
|
|
74
|
+
if isinstance(recommendations, list):
|
|
75
|
+
if recommendations:
|
|
76
|
+
click.echo("\nNext steps:")
|
|
77
|
+
for recommendation in recommendations:
|
|
78
|
+
if not isinstance(recommendation, dict):
|
|
79
|
+
continue
|
|
80
|
+
if recommendation.get("message"):
|
|
81
|
+
click.echo(str(recommendation["message"]))
|
|
82
|
+
if recommendation.get("cwd"):
|
|
83
|
+
click.echo(f"Working directory: {recommendation['cwd']}")
|
|
84
|
+
argv = recommendation.get("argv")
|
|
85
|
+
if isinstance(argv, list) and all(
|
|
86
|
+
isinstance(argument, str) for argument in argv
|
|
87
|
+
):
|
|
88
|
+
click.echo(
|
|
89
|
+
"Argument vector (execute without a shell): "
|
|
90
|
+
+ json.dumps(argv, ensure_ascii=False)
|
|
91
|
+
)
|
|
92
|
+
elif isinstance(value, dict) and "items" in value:
|
|
93
|
+
for item in value["items"]:
|
|
94
|
+
click.echo(
|
|
95
|
+
f"{item.get('id', item.get('path'))}: {item.get('title', item.get('heading', ''))}"
|
|
96
|
+
)
|
|
97
|
+
click.echo(
|
|
98
|
+
f"total={value['total']} has_more={value['has_more']} truncated={value['truncated']}"
|
|
99
|
+
)
|
|
100
|
+
elif isinstance(value, dict) and "text" in value:
|
|
101
|
+
click.echo(value["text"])
|
|
102
|
+
elif isinstance(value, dict) and "diagnostics" in value:
|
|
103
|
+
for item in value["diagnostics"]:
|
|
104
|
+
click.echo(
|
|
105
|
+
f"{item['level']}: {item.get('path', '')}: {item['code']}: {item['message']}",
|
|
106
|
+
err=True,
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
click.echo(str(value))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolved_options(
|
|
113
|
+
ctx: click.Context, local_root: Path | None, local_json: bool
|
|
114
|
+
) -> tuple[Path, bool]:
|
|
115
|
+
root = (local_root or ctx.find_root().obj["root"]).resolve()
|
|
116
|
+
if not root.is_dir():
|
|
117
|
+
raise click.UsageError(f"--root is not a directory: {root}")
|
|
118
|
+
return root, local_json or bool(ctx.find_root().obj["json"])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def query_features(root: Path) -> tuple[list[Any], list[Any]]:
|
|
122
|
+
features, diagnostics = scan_features(root)
|
|
123
|
+
errors = validate_query_metadata(features) + diagnostics
|
|
124
|
+
return features, errors
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def report(
|
|
128
|
+
ctx: click.Context,
|
|
129
|
+
root: Path | None,
|
|
130
|
+
as_json: bool,
|
|
131
|
+
action: Callable[[Path], tuple[Any, list[Any]]],
|
|
132
|
+
name: str,
|
|
133
|
+
) -> None:
|
|
134
|
+
selected_root, selected_json = resolved_options(ctx, root, as_json)
|
|
135
|
+
records, diagnostics = action(selected_root)
|
|
136
|
+
payload = {
|
|
137
|
+
"command": name,
|
|
138
|
+
"diagnostics": [item.json() for item in diagnostics],
|
|
139
|
+
"ok": not any(item.level == "error" for item in diagnostics),
|
|
140
|
+
}
|
|
141
|
+
payload.update({f"{kind}s": len(items) for kind, items in records.items()})
|
|
142
|
+
emit(payload, selected_json)
|
|
143
|
+
if not payload["ok"]:
|
|
144
|
+
raise click.exceptions.Exit(1)
|