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.
Files changed (65) hide show
  1. alku/__init__.py +3 -0
  2. alku/__main__.py +4 -0
  3. alku/agent_configs.py +269 -0
  4. alku/claude.py +144 -0
  5. alku/cli.py +35 -0
  6. alku/codebase_memory.py +51 -0
  7. alku/commands/__init__.py +1 -0
  8. alku/commands/common.py +144 -0
  9. alku/commands/docs.py +244 -0
  10. alku/commands/init.py +67 -0
  11. alku/commands/self.py +45 -0
  12. alku/file_transactions.py +484 -0
  13. alku/gate.py +230 -0
  14. alku/generation/__init__.py +5 -0
  15. alku/generation/diagnostics.py +252 -0
  16. alku/generation/orchestration.py +396 -0
  17. alku/generation/rendering.py +241 -0
  18. alku/generation/transactions.py +588 -0
  19. alku/markdown.py +208 -0
  20. alku/migrations/__init__.py +26 -0
  21. alku/migrations/base.py +58 -0
  22. alku/migrations/registry.py +7 -0
  23. alku/migrations/runner.py +103 -0
  24. alku/model_config.py +212 -0
  25. alku/models.py +125 -0
  26. alku/models.yml +28 -0
  27. alku/paths.py +102 -0
  28. alku/pipeline/__init__.py +25 -0
  29. alku/pipeline/base.py +97 -0
  30. alku/pipeline/context.py +162 -0
  31. alku/pipeline/runner.py +136 -0
  32. alku/pipeline/steps.py +396 -0
  33. alku/proposal/__init__.py +1 -0
  34. alku/proposal/identifiers.py +96 -0
  35. alku/proposal/planning.py +78 -0
  36. alku/proposal/rewriting.py +414 -0
  37. alku/proposal/transaction.py +231 -0
  38. alku/proposal/types.py +65 -0
  39. alku/providers.py +147 -0
  40. alku/queries.py +257 -0
  41. alku/repository.py +345 -0
  42. alku/skill_catalog.py +154 -0
  43. alku/skill_planning.py +203 -0
  44. alku/skill_sync.py +134 -0
  45. alku/skill_types.py +37 -0
  46. alku/skill_writes.py +262 -0
  47. alku/skills/alku-architecture/SKILL.md +70 -0
  48. alku/skills/alku-auto/SKILL.md +58 -0
  49. alku/skills/alku-brainstorm/SKILL.md +24 -0
  50. alku/skills/alku-docs/SKILL.md +20 -0
  51. alku/skills/alku-workflow/SKILL.md +57 -0
  52. alku/skills/alku-workflow/references/develop.md +15 -0
  53. alku/skills/alku-workflow/references/research.md +15 -0
  54. alku/skills/alku-workflow/references/review.md +20 -0
  55. alku/structure.py +150 -0
  56. alku/templates/README.md +76 -0
  57. alku/templates/gate.md +8 -0
  58. alku/tooling.py +425 -0
  59. alku/updates.py +244 -0
  60. alku/validation.py +559 -0
  61. alku-0.5.0.dist-info/METADATA +92 -0
  62. alku-0.5.0.dist-info/RECORD +65 -0
  63. alku-0.5.0.dist-info/WHEEL +4 -0
  64. alku-0.5.0.dist-info/entry_points.txt +2 -0
  65. alku-0.5.0.dist-info/licenses/LICENSE +21 -0
alku/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Alku repository bootstrapper and documentation maintainer."""
2
+
3
+ __version__ = "0.5.0"
alku/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
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)
@@ -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`."""
@@ -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)