@grifhinz/logics-manager 2.0.0

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.
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .assist import _build_claude_bridge_manifest, _build_claude_instructions
7
+
8
+
9
+ WORKFLOW_DIRS: tuple[str, ...] = ("request", "backlog", "tasks", "specs", "product", "architecture", "external", ".cache")
10
+
11
+
12
+ def _workflow_directories(repo_root: Path) -> list[Path]:
13
+ return [repo_root / "logics" / name for name in WORKFLOW_DIRS]
14
+
15
+
16
+ def bootstrap_payload(repo_root: Path, *, check: bool) -> dict[str, object]:
17
+ logics_root = repo_root / "logics"
18
+ bridge_manifest = _build_claude_bridge_manifest(repo_root)
19
+ instructions_manifest = _build_claude_instructions(repo_root)
20
+ directory_actions: list[dict[str, object]] = []
21
+ created_paths: list[str] = []
22
+ missing_paths: list[str] = []
23
+
24
+ if not logics_root.exists():
25
+ missing_paths.append("logics/")
26
+ elif not logics_root.is_dir():
27
+ raise SystemExit(f"`{logics_root}` exists but is not a directory.")
28
+
29
+ if not check and not logics_root.exists():
30
+ logics_root.mkdir(parents=True, exist_ok=True)
31
+ created_paths.append("logics/")
32
+
33
+ for directory in _workflow_directories(repo_root):
34
+ relative = directory.relative_to(repo_root).as_posix()
35
+ needs_create = not directory.exists()
36
+ needs_gitkeep = (
37
+ True
38
+ if not directory.exists()
39
+ else directory.is_dir()
40
+ and not any(entry.is_file() for entry in directory.iterdir())
41
+ and not (directory / ".gitkeep").exists()
42
+ )
43
+ directory_actions.append({"path": relative, "exists": directory.exists(), "needs_gitkeep": needs_gitkeep})
44
+ if needs_create:
45
+ missing_paths.append(relative + "/")
46
+ if not check:
47
+ directory.mkdir(parents=True, exist_ok=True)
48
+ created_paths.append(relative + "/")
49
+ gitkeep = directory / ".gitkeep"
50
+ if not gitkeep.exists():
51
+ gitkeep.write_text("", encoding="utf-8")
52
+ created_paths.append(f"{relative}/.gitkeep")
53
+ elif needs_gitkeep:
54
+ missing_paths.append(f"{relative}/.gitkeep")
55
+ if not check:
56
+ (directory / ".gitkeep").write_text("", encoding="utf-8")
57
+ created_paths.append(f"{relative}/.gitkeep")
58
+
59
+ instructions_path = logics_root / "instructions.md"
60
+ instructions_content = str(instructions_manifest["content"])
61
+ instructions_missing = not instructions_path.exists()
62
+ instructions_stale = False
63
+ if not instructions_missing:
64
+ try:
65
+ instructions_stale = instructions_path.read_text(encoding="utf-8") != instructions_content
66
+ except Exception:
67
+ instructions_stale = True
68
+ if instructions_missing or instructions_stale:
69
+ missing_paths.append("logics/instructions.md")
70
+ if not check:
71
+ instructions_path.write_text(instructions_content, encoding="utf-8")
72
+ created_paths.append("logics/instructions.md")
73
+
74
+ for bridge in bridge_manifest["bridges"]:
75
+ for rel_path, content in (
76
+ (str(bridge["command_path"]), str(bridge["command_content"])),
77
+ (str(bridge["agent_path"]), str(bridge["agent_content"])),
78
+ ):
79
+ bridge_path = repo_root / rel_path
80
+ if bridge_path.exists():
81
+ try:
82
+ if bridge_path.read_text(encoding="utf-8") == content:
83
+ continue
84
+ except Exception:
85
+ pass
86
+ missing_paths.append(rel_path)
87
+ if not check:
88
+ bridge_path.parent.mkdir(parents=True, exist_ok=True)
89
+ bridge_path.write_text(content, encoding="utf-8")
90
+ created_paths.append(rel_path)
91
+
92
+ ok = not missing_paths if check else True
93
+ return {
94
+ "command": "bootstrap",
95
+ "repo_root": repo_root.as_posix(),
96
+ "check": check,
97
+ "ok": ok,
98
+ "missing_paths": missing_paths,
99
+ "created_paths": created_paths,
100
+ "directory_actions": directory_actions,
101
+ "claude_bridge_count": bridge_manifest["bridge_count"],
102
+ "claude_instruction_line_count": instructions_manifest["line_count"],
103
+ }
104
+
105
+
106
+ def render_bootstrap(payload: dict[str, object], *, output_format: str) -> str:
107
+ if output_format == "json":
108
+ return json.dumps(payload, indent=2, sort_keys=True)
109
+ if payload["check"]:
110
+ if payload["ok"]:
111
+ return "Bootstrap check: OK"
112
+ lines = ["Bootstrap check: actions required"]
113
+ for path in payload["missing_paths"]:
114
+ lines.append(f"- missing: {path}")
115
+ return "\n".join(lines)
116
+ lines = ["Bootstrap: OK"]
117
+ if payload["created_paths"]:
118
+ lines.append("- created:")
119
+ for path in payload["created_paths"]:
120
+ lines.append(f" - {path}")
121
+ else:
122
+ lines.append("- nothing to create")
123
+ return "\n".join(lines)
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from importlib import metadata
5
+ import sys
6
+ from pathlib import Path
7
+ from textwrap import dedent
8
+
9
+ from .bootstrap import bootstrap_payload, render_bootstrap
10
+ from .assist import main as assist_main
11
+ from .audit import audit_payload, build_parser as build_audit_parser
12
+ from .audit import render_audit
13
+ from .config import ConfigError, find_repo_root, render_config_show
14
+ from .index import index_payload, render_index
15
+ from .lint import lint_payload, render_lint
16
+ from .doctor import render_doctor
17
+
18
+
19
+ def get_cli_version() -> str:
20
+ version_file = Path(__file__).resolve().parents[1] / "VERSION"
21
+ try:
22
+ version = version_file.read_text(encoding="utf-8").strip()
23
+ except OSError:
24
+ version = ""
25
+ if version:
26
+ return version
27
+
28
+ try:
29
+ return metadata.version("logics-manager")
30
+ except metadata.PackageNotFoundError:
31
+ pass
32
+ return "0.0.0"
33
+
34
+
35
+ def main(argv: list[str] | None = None) -> int:
36
+ if argv is None:
37
+ argv = sys.argv[1:]
38
+ parser = argparse.ArgumentParser(
39
+ prog="logics-manager",
40
+ description="Canonical Logics CLI for workflow, validation, and runtime operations.",
41
+ formatter_class=argparse.RawDescriptionHelpFormatter,
42
+ epilog=dedent(
43
+ """
44
+ Examples:
45
+ logics-manager flow new request --title "My request"
46
+ logics-manager audit
47
+ logics-manager config show --format json
48
+ """
49
+ ).strip(),
50
+ )
51
+ parser.add_argument("--version", action="version", version=f"logics-manager {get_cli_version()}")
52
+ parser.add_argument(
53
+ "command",
54
+ nargs="?",
55
+ choices=("bootstrap", "flow", "sync", "assist", "audit", "index", "lint", "config", "doctor"),
56
+ )
57
+ parser.add_argument("rest", nargs=argparse.REMAINDER)
58
+ args = parser.parse_args(argv[:1])
59
+
60
+ if args.command is None:
61
+ parser.print_help()
62
+ return 1
63
+
64
+ rest = argv[1:]
65
+ if args.command == "config":
66
+ if not rest or rest[0] != "show":
67
+ raise SystemExit("Usage: logics-manager config show [args...]")
68
+ config_args = rest[1:]
69
+ parser = argparse.ArgumentParser(prog="logics-manager config show", add_help=False)
70
+ parser.add_argument("--format", choices=("text", "json"), default="text")
71
+ parsed, _unknown = parser.parse_known_args(config_args)
72
+ repo_root = find_repo_root(Path.cwd())
73
+ try:
74
+ output = render_config_show(repo_root, output_format=parsed.format)
75
+ except ConfigError as exc:
76
+ raise SystemExit(str(exc)) from exc
77
+ print(output)
78
+ return 0
79
+ if args.command == "doctor":
80
+ doctor_args = rest
81
+ parser = argparse.ArgumentParser(prog="logics-manager doctor", add_help=False)
82
+ parser.add_argument("--format", choices=("text", "json"), default="text")
83
+ parsed, _unknown = parser.parse_known_args(doctor_args)
84
+ repo_root = find_repo_root(Path.cwd())
85
+ try:
86
+ output = render_doctor(repo_root, output_format=parsed.format)
87
+ except ConfigError as exc:
88
+ raise SystemExit(str(exc)) from exc
89
+ print(output)
90
+ return 0
91
+ if args.command == "bootstrap":
92
+ parser = argparse.ArgumentParser(prog="logics-manager bootstrap", add_help=False)
93
+ parser.add_argument("--check", action="store_true")
94
+ parser.add_argument("--format", choices=("text", "json"), default="text")
95
+ parsed, _unknown = parser.parse_known_args(rest)
96
+ try:
97
+ repo_root = find_repo_root(Path.cwd())
98
+ except ConfigError:
99
+ repo_root = Path.cwd().resolve()
100
+ payload = bootstrap_payload(repo_root, check=parsed.check)
101
+ print(render_bootstrap(payload, output_format=parsed.format))
102
+ return 0 if payload["ok"] else 1
103
+ if args.command == "flow" and rest[:1] in (["new"], ["companion"], ["promote"], ["split"], ["close"], ["finish"]):
104
+ from .flow import main as flow_main
105
+
106
+ return flow_main(rest)
107
+ if args.command == "sync":
108
+ if rest[:1] not in (["close-eligible-requests"], ["refresh-mermaid-signatures"], ["schema-status"], ["context-pack"], ["export-graph"]):
109
+ raise SystemExit("Unsupported sync subcommand for the native CLI slice.")
110
+ from .sync import main as sync_main
111
+
112
+ return sync_main(rest)
113
+ if args.command == "assist":
114
+ if rest[:1] not in (["runtime-status"], ["diff-risk"], ["commit-plan"], ["changed-surface-summary"], ["doc-consistency"], ["review-checklist"], ["validation-checklist"], ["validation-summary"], ["test-impact-summary"], ["roi-report"], ["next-step"], ["claude-bridges"], ["claude-instructions"], ["request-draft"], ["spec-first-pass"], ["backlog-groom"], ["closure-summary"], ["context"]):
115
+ raise SystemExit("Unsupported assist subcommand for the native CLI slice.")
116
+ return assist_main(rest)
117
+ if args.command == "audit":
118
+ audit_parser = build_audit_parser()
119
+ parsed, _unknown = audit_parser.parse_known_args(rest)
120
+ repo_root = find_repo_root(Path.cwd())
121
+ try:
122
+ payload = audit_payload(
123
+ repo_root,
124
+ stale_days=parsed.stale_days,
125
+ skip_ac_traceability=parsed.skip_ac_traceability,
126
+ skip_gates=parsed.skip_gates,
127
+ legacy_cutoff_version=parsed.legacy_cutoff_version,
128
+ group_by_doc=parsed.group_by_doc,
129
+ autofix_ac_traceability=parsed.autofix_ac_traceability,
130
+ paths=parsed.paths,
131
+ refs=parsed.refs,
132
+ since_version=parsed.since_version,
133
+ token_hygiene=parsed.token_hygiene,
134
+ autofix_structure=parsed.autofix_structure,
135
+ governance_profile=parsed.governance_profile,
136
+ )
137
+ output = render_audit(
138
+ repo_root,
139
+ stale_days=parsed.stale_days,
140
+ skip_ac_traceability=parsed.skip_ac_traceability,
141
+ skip_gates=parsed.skip_gates,
142
+ legacy_cutoff_version=parsed.legacy_cutoff_version,
143
+ output_format=parsed.format,
144
+ group_by_doc=parsed.group_by_doc,
145
+ autofix_ac_traceability=parsed.autofix_ac_traceability,
146
+ paths=parsed.paths,
147
+ refs=parsed.refs,
148
+ since_version=parsed.since_version,
149
+ token_hygiene=parsed.token_hygiene,
150
+ autofix_structure=parsed.autofix_structure,
151
+ governance_profile=parsed.governance_profile,
152
+ )
153
+ except ConfigError as exc:
154
+ raise SystemExit(str(exc)) from exc
155
+ print(output)
156
+ return 0 if payload["ok"] else 1
157
+ if args.command == "index":
158
+ parser = argparse.ArgumentParser(prog="logics-manager index", add_help=False)
159
+ parser.add_argument("--out", default="logics/INDEX.md")
160
+ parser.add_argument("--format", choices=("text", "json"), default="text")
161
+ parsed, _unknown = parser.parse_known_args(rest)
162
+ repo_root = find_repo_root(Path.cwd())
163
+ try:
164
+ payload = index_payload(repo_root, out=parsed.out)
165
+ except ConfigError as exc:
166
+ raise SystemExit(str(exc)) from exc
167
+ output = render_index(repo_root, out=parsed.out, output_format=parsed.format) if parsed.format == "json" else f"Wrote {payload['output_path']}"
168
+ print(output)
169
+ return 0 if payload["ok"] else 1
170
+ if args.command == "lint":
171
+ parser = argparse.ArgumentParser(prog="logics-manager lint", add_help=False)
172
+ parser.add_argument("--require-status", action="store_true")
173
+ parser.add_argument("--format", choices=("text", "json"), default="text")
174
+ parsed, _unknown = parser.parse_known_args(rest)
175
+ repo_root = find_repo_root(Path.cwd())
176
+ try:
177
+ payload = lint_payload(repo_root, require_status=parsed.require_status)
178
+ output = render_lint(repo_root, require_status=parsed.require_status, output_format=parsed.format)
179
+ except ConfigError as exc:
180
+ raise SystemExit(str(exc)) from exc
181
+ print(output)
182
+ return 0 if payload["ok"] else 1
183
+ raise SystemExit(f"Unsupported command: {args.command}")
@@ -0,0 +1,251 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from copy import deepcopy
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ DEFAULT_LOGICS_CONFIG: dict[str, Any] = {
10
+ "version": 1,
11
+ "workflow": {
12
+ "split": {
13
+ "policy": "minimal-coherent",
14
+ "max_children_without_override": 4,
15
+ }
16
+ },
17
+ "mutations": {
18
+ "mode": "transactional",
19
+ "audit_log": "logics/mutation_audit.jsonl",
20
+ },
21
+ "index": {
22
+ "enabled": True,
23
+ "path": "logics/.cache/runtime_index.json",
24
+ },
25
+ "hybrid_assist": {
26
+ "default_backend": "auto",
27
+ "next_step_auto_backend": None,
28
+ "default_model_profile": "deepseek-coder",
29
+ "default_model": "deepseek-coder-v2:16b",
30
+ "env_file": ".env",
31
+ "result_cache": {
32
+ "enabled": True,
33
+ "path": "logics/.cache/flow_results_cache.json",
34
+ "ttl_seconds": 600,
35
+ },
36
+ "model_profiles": {
37
+ "deepseek-coder": {
38
+ "family": "deepseek",
39
+ "model": "deepseek-coder-v2:16b",
40
+ "description": "DeepSeek Coder V2 profile for shared local coding and hybrid assist work.",
41
+ "example_tags": ["deepseek-coder-v2:16b", "deepseek-coder-v2:latest"],
42
+ },
43
+ "qwen-coder": {
44
+ "family": "qwen",
45
+ "model": "qwen2.5-coder:14b",
46
+ "description": "Qwen coder profile for bounded local coding and hybrid assist work.",
47
+ "example_tags": ["qwen2.5-coder:14b", "qwen2.5-coder:7b"],
48
+ },
49
+ },
50
+ "ollama_host": "http://127.0.0.1:11434",
51
+ "timeout_seconds": 20.0,
52
+ "audit_log": "logics/.cache/hybrid_assist_audit.jsonl",
53
+ "measurement_log": "logics/.cache/hybrid_assist_measurements.jsonl",
54
+ "provider_health_path": "logics/.cache/provider_health.json",
55
+ "providers": {
56
+ "readiness_cooldown_seconds": 300,
57
+ "ollama": {
58
+ "enabled": True,
59
+ "host": "http://127.0.0.1:11434",
60
+ },
61
+ "openai": {
62
+ "enabled": False,
63
+ "base_url": "https://api.openai.com/v1",
64
+ "model": "gpt-4.1-mini",
65
+ "api_key_env": "OPENAI_API_KEY",
66
+ },
67
+ "gemini": {
68
+ "enabled": False,
69
+ "base_url": "https://generativelanguage.googleapis.com/v1beta",
70
+ "model": "gemini-2.0-flash",
71
+ "api_key_env": "GEMINI_API_KEY",
72
+ },
73
+ },
74
+ },
75
+ }
76
+
77
+
78
+ class ConfigError(SystemExit):
79
+ pass
80
+
81
+
82
+ def _strip_comment(value: str) -> str:
83
+ stripped = value.strip()
84
+ if not stripped.startswith(('"', "'")) and "#" in stripped:
85
+ stripped = stripped.split("#", 1)[0].rstrip()
86
+ return stripped
87
+
88
+
89
+ def _coerce_scalar(value: str) -> Any:
90
+ stripped = _strip_comment(value)
91
+ if stripped in {"", "null", "Null", "NULL", "~"}:
92
+ return None
93
+ if stripped in {"true", "True"}:
94
+ return True
95
+ if stripped in {"false", "False"}:
96
+ return False
97
+ if stripped.startswith(("'", '"')) and stripped.endswith(("'", '"')) and len(stripped) >= 2:
98
+ return stripped[1:-1]
99
+ try:
100
+ return int(stripped)
101
+ except ValueError:
102
+ pass
103
+ try:
104
+ return float(stripped)
105
+ except ValueError:
106
+ pass
107
+ return stripped
108
+
109
+
110
+ def _prepared_lines(text: str) -> list[tuple[int, str]]:
111
+ prepared: list[tuple[int, str]] = []
112
+ for raw in text.splitlines():
113
+ if not raw.strip():
114
+ continue
115
+ stripped = raw.lstrip(" ")
116
+ if stripped.startswith("#"):
117
+ continue
118
+ indent = len(raw) - len(stripped)
119
+ prepared.append((indent, stripped.rstrip()))
120
+ return prepared
121
+
122
+
123
+ def _parse_block(lines: list[tuple[int, str]], index: int, indent: int) -> tuple[Any, int]:
124
+ if index >= len(lines):
125
+ return {}, index
126
+ current_indent, content = lines[index]
127
+ if current_indent < indent:
128
+ return {}, index
129
+
130
+ if content.startswith("- "):
131
+ items: list[Any] = []
132
+ while index < len(lines):
133
+ current_indent, content = lines[index]
134
+ if current_indent < indent:
135
+ break
136
+ if current_indent != indent or not content.startswith("- "):
137
+ raise ConfigError(f"Invalid list indentation in logics.yaml near `{content}`.")
138
+ item_content = content[2:].strip()
139
+ index += 1
140
+ if not item_content:
141
+ if index < len(lines) and lines[index][0] > indent:
142
+ nested_indent = lines[index][0]
143
+ value, index = _parse_block(lines, index, nested_indent)
144
+ else:
145
+ value = None
146
+ elif ":" in item_content and not item_content.startswith(("'", '"')) and not item_content.endswith(":"):
147
+ key, raw_value = item_content.split(":", 1)
148
+ value = {key.strip(): _coerce_scalar(raw_value.strip())}
149
+ elif item_content.endswith(":") and not item_content.startswith(("'", '"')):
150
+ key = item_content[:-1].strip()
151
+ if index < len(lines) and lines[index][0] > indent:
152
+ nested_indent = lines[index][0]
153
+ nested_value, index = _parse_block(lines, index, nested_indent)
154
+ else:
155
+ nested_value = {}
156
+ value = {key: nested_value}
157
+ else:
158
+ value = _coerce_scalar(item_content)
159
+ items.append(value)
160
+ return items, index
161
+
162
+ mapping: dict[str, Any] = {}
163
+ while index < len(lines):
164
+ current_indent, content = lines[index]
165
+ if current_indent < indent:
166
+ break
167
+ if current_indent != indent:
168
+ raise ConfigError(f"Invalid mapping indentation in logics.yaml near `{content}`.")
169
+ if ":" not in content:
170
+ raise ConfigError(f"Expected `key: value` in logics.yaml, got `{content}`.")
171
+ key, raw_value = content.split(":", 1)
172
+ key = key.strip()
173
+ raw_value = raw_value.strip()
174
+ index += 1
175
+ if raw_value:
176
+ mapping[key] = _coerce_scalar(raw_value)
177
+ continue
178
+ if index < len(lines) and lines[index][0] > current_indent:
179
+ nested_indent = lines[index][0]
180
+ value, index = _parse_block(lines, index, nested_indent)
181
+ else:
182
+ value = {}
183
+ mapping[key] = value
184
+ return mapping, index
185
+
186
+
187
+ def parse_simple_yaml(text: str) -> dict[str, Any]:
188
+ lines = _prepared_lines(text)
189
+ if not lines:
190
+ return {}
191
+ parsed, index = _parse_block(lines, 0, lines[0][0])
192
+ if index != len(lines):
193
+ raise ConfigError("Could not parse the full logics.yaml payload.")
194
+ if not isinstance(parsed, dict):
195
+ raise ConfigError("logics.yaml must decode to a top-level mapping.")
196
+ return parsed
197
+
198
+
199
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
200
+ merged = deepcopy(base)
201
+ for key, value in override.items():
202
+ current = merged.get(key)
203
+ if isinstance(current, dict) and isinstance(value, dict):
204
+ merged[key] = _deep_merge(current, value)
205
+ else:
206
+ merged[key] = value
207
+ return merged
208
+
209
+
210
+ def find_repo_root(start: Path) -> Path:
211
+ current = start.resolve()
212
+ for candidate in [current, *current.parents]:
213
+ if (candidate / "logics").is_dir():
214
+ return candidate
215
+ raise ConfigError("Could not locate repo root (missing 'logics/' directory). Run from inside the repo.")
216
+
217
+
218
+ def config_path(repo_root: Path) -> Path:
219
+ return repo_root / "logics.yaml"
220
+
221
+
222
+ def load_repo_config(repo_root: Path) -> tuple[dict[str, Any], Path | None]:
223
+ path = config_path(repo_root)
224
+ if not path.is_file():
225
+ return deepcopy(DEFAULT_LOGICS_CONFIG), None
226
+ try:
227
+ override = parse_simple_yaml(path.read_text(encoding="utf-8"))
228
+ except ConfigError:
229
+ raise
230
+ except Exception as exc:
231
+ raise ConfigError(f"Failed to parse {path.relative_to(repo_root)}: {exc}") from exc
232
+ return _deep_merge(DEFAULT_LOGICS_CONFIG, override), path
233
+
234
+
235
+ def render_config_show(repo_root: Path, *, output_format: str = "text") -> str:
236
+ config, path = load_repo_config(repo_root)
237
+ payload = {
238
+ "config_path": str(path.relative_to(repo_root)) if path is not None else None,
239
+ "config": config,
240
+ }
241
+ if output_format == "json":
242
+ return json.dumps(payload, indent=2, sort_keys=True)
243
+
244
+ lines = [
245
+ f"Config source: {payload['config_path'] or '<defaults>'}",
246
+ f"Version: {config.get('version', 'unknown')}",
247
+ f"Workflow split policy: {config.get('workflow', {}).get('split', {}).get('policy', 'unknown')}",
248
+ f"Default backend: {config.get('hybrid_assist', {}).get('default_backend', 'unknown')}",
249
+ f"Default model: {config.get('hybrid_assist', {}).get('default_model', 'unknown')}",
250
+ ]
251
+ return "\n".join(lines)
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .config import ConfigError, load_repo_config
10
+
11
+
12
+ REQUIRED_DIRECTORIES = ("logics/request", "logics/backlog", "logics/tasks")
13
+ SCHEMA_VERSION_PATTERN = re.compile(r"^\s*>\s*Schema version:\s*(.+?)\s*$", re.MULTILINE)
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DoctorIssue:
18
+ code: str
19
+ path: str
20
+ message: str
21
+ remediation: str
22
+
23
+ def to_dict(self) -> dict[str, str]:
24
+ return {
25
+ "code": self.code,
26
+ "path": self.path,
27
+ "message": self.message,
28
+ "remediation": self.remediation,
29
+ }
30
+
31
+
32
+ def _check_required_directories(repo_root: Path) -> list[DoctorIssue]:
33
+ issues: list[DoctorIssue] = []
34
+ for relative in REQUIRED_DIRECTORIES:
35
+ candidate = repo_root / relative
36
+ if candidate.is_dir():
37
+ continue
38
+ issues.append(
39
+ DoctorIssue(
40
+ code="missing_directory",
41
+ path=relative,
42
+ message=f"Missing required directory `{relative}`.",
43
+ remediation=f"Create `{relative}` or bootstrap the Logics workflow corpus.",
44
+ )
45
+ )
46
+ return issues
47
+
48
+
49
+ def _check_schema_versions(repo_root: Path) -> list[DoctorIssue]:
50
+ issues: list[DoctorIssue] = []
51
+ for directory in REQUIRED_DIRECTORIES:
52
+ candidate_dir = repo_root / directory
53
+ if not candidate_dir.is_dir():
54
+ continue
55
+ for doc_path in sorted(candidate_dir.glob("*.md")):
56
+ try:
57
+ text = doc_path.read_text(encoding="utf-8")
58
+ except Exception as exc: # pragma: no cover - defensive filesystem guard
59
+ issues.append(
60
+ DoctorIssue(
61
+ code="unreadable_doc",
62
+ path=doc_path.relative_to(repo_root).as_posix(),
63
+ message=f"Could not read workflow doc: {exc}",
64
+ remediation="Fix the file permissions or remove the broken file.",
65
+ )
66
+ )
67
+ continue
68
+ if SCHEMA_VERSION_PATTERN.search(text):
69
+ continue
70
+ issues.append(
71
+ DoctorIssue(
72
+ code="missing_schema_version",
73
+ path=doc_path.relative_to(repo_root).as_posix(),
74
+ message="Workflow doc is missing a schema version indicator.",
75
+ remediation="Add `> Schema version: 1.0` near the top of the document.",
76
+ )
77
+ )
78
+ return issues
79
+
80
+
81
+ def doctor_payload(repo_root: Path) -> dict[str, Any]:
82
+ issues: list[DoctorIssue] = []
83
+ issues.extend(_check_required_directories(repo_root))
84
+
85
+ config_path = None
86
+ try:
87
+ _config, config_path = load_repo_config(repo_root)
88
+ except ConfigError as exc:
89
+ issues.append(
90
+ DoctorIssue(
91
+ code="invalid_config",
92
+ path="logics.yaml",
93
+ message=str(exc),
94
+ remediation="Fix `logics.yaml` so the runtime config can be parsed.",
95
+ )
96
+ )
97
+
98
+ issues.extend(_check_schema_versions(repo_root))
99
+ payload = {
100
+ "ok": not issues,
101
+ "issue_count": len(issues),
102
+ "issues": [issue.to_dict() for issue in issues],
103
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
104
+ "workflow_doc_count": sum(1 for directory in REQUIRED_DIRECTORIES for _ in (repo_root / directory).glob("*.md") if (repo_root / directory).is_dir()),
105
+ "missing_schema_version_count": sum(1 for issue in issues if issue.code == "missing_schema_version"),
106
+ }
107
+ return payload
108
+
109
+
110
+ def render_doctor(repo_root: Path, *, output_format: str = "text") -> str:
111
+ payload = doctor_payload(repo_root)
112
+ if output_format == "json":
113
+ return json.dumps(payload, indent=2, sort_keys=True)
114
+
115
+ lines = [
116
+ "Logics doctor: OK" if payload["ok"] else "Logics doctor: FAILED",
117
+ f"Workflow docs inspected: {payload['workflow_doc_count']}",
118
+ ]
119
+ if payload["issues"]:
120
+ max_issues = 10
121
+ for issue in payload["issues"][:max_issues]:
122
+ lines.append(f"- [{issue['code']}] {issue['path']}: {issue['message']}")
123
+ lines.append(f" remediation: {issue['remediation']}")
124
+ remaining = len(payload["issues"]) - max_issues
125
+ if remaining > 0:
126
+ lines.append(f"... and {remaining} more issue(s).")
127
+ return "\n".join(lines)