@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.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/VERSION +1 -0
- package/logics_manager/__init__.py +5 -0
- package/logics_manager/__main__.py +9 -0
- package/logics_manager/assist.py +2211 -0
- package/logics_manager/audit.py +990 -0
- package/logics_manager/bootstrap.py +123 -0
- package/logics_manager/cli.py +183 -0
- package/logics_manager/config.py +251 -0
- package/logics_manager/doctor.py +127 -0
- package/logics_manager/flow.py +1449 -0
- package/logics_manager/index.py +142 -0
- package/logics_manager/lint.py +622 -0
- package/logics_manager/sync.py +604 -0
- package/package.json +162 -0
- package/pyproject.toml +15 -0
- package/scripts/logics-manager.py +15 -0
- package/scripts/npm/logics-manager.mjs +96 -0
|
@@ -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)
|