@grifhinz/logics-manager 2.1.2 → 2.2.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/README.md +47 -4
- package/VERSION +1 -1
- package/logics_manager/assist.py +185 -21
- package/logics_manager/cli.py +132 -12
- package/logics_manager/cli_output.py +18 -0
- package/logics_manager/flow.py +1257 -83
- package/logics_manager/index.py +3 -7
- package/logics_manager/insights.py +418 -0
- package/logics_manager/mcp.py +50 -0
- package/logics_manager/path_utils.py +31 -0
- package/logics_manager/sync.py +24 -12
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/logics_manager/index.py
CHANGED
|
@@ -8,6 +8,7 @@ from os import path as os_path
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
from .config import find_repo_root
|
|
11
|
+
from .path_utils import resolve_repo_output_path
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@dataclass(frozen=True)
|
|
@@ -91,7 +92,7 @@ def index_payload(repo_root: Path, *, out: str = "logics/INDEX.md") -> dict[str,
|
|
|
91
92
|
for title, rel_dir, show_progress in SECTION_DEFINITIONS:
|
|
92
93
|
sections.append((title, _collect_entries(repo_root, rel_dir), show_progress))
|
|
93
94
|
|
|
94
|
-
out_path = (repo_root
|
|
95
|
+
out_path, output_path = resolve_repo_output_path(repo_root, out)
|
|
95
96
|
out_dir = out_path.parent
|
|
96
97
|
content = "\n".join(
|
|
97
98
|
[
|
|
@@ -104,15 +105,10 @@ def index_payload(repo_root: Path, *, out: str = "logics/INDEX.md") -> dict[str,
|
|
|
104
105
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
106
|
out_path.write_text(content, encoding="utf-8")
|
|
106
107
|
|
|
107
|
-
try:
|
|
108
|
-
printable = out_path.relative_to(repo_root)
|
|
109
|
-
except ValueError:
|
|
110
|
-
printable = out_path
|
|
111
|
-
|
|
112
108
|
counts = {key: len(entries) for key, (_, entries, _) in zip(SECTION_COUNT_KEYS, sections)}
|
|
113
109
|
return {
|
|
114
110
|
"ok": True,
|
|
115
|
-
"output_path":
|
|
111
|
+
"output_path": output_path,
|
|
116
112
|
"counts": counts,
|
|
117
113
|
}
|
|
118
114
|
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
WORKFLOW_KINDS = ("request", "backlog", "task")
|
|
11
|
+
COMPANION_KINDS = ("product", "architecture")
|
|
12
|
+
OPEN_STATUSES = {"Draft", "Ready", "In progress", "Blocked"}
|
|
13
|
+
CLOSED_STATUSES = {"Done", "Archived"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class LogicsDoc:
|
|
18
|
+
kind: str
|
|
19
|
+
path: Path
|
|
20
|
+
rel_path: str
|
|
21
|
+
ref: str
|
|
22
|
+
title: str
|
|
23
|
+
status: str | None
|
|
24
|
+
progress: int | None
|
|
25
|
+
content: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _doc_dirs(repo_root: Path) -> dict[str, Path]:
|
|
29
|
+
return {
|
|
30
|
+
"request": repo_root / "logics" / "request",
|
|
31
|
+
"backlog": repo_root / "logics" / "backlog",
|
|
32
|
+
"task": repo_root / "logics" / "tasks",
|
|
33
|
+
"product": repo_root / "logics" / "product",
|
|
34
|
+
"architecture": repo_root / "logics" / "architecture",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _progress_value(raw: str | None) -> int | None:
|
|
39
|
+
if raw is None:
|
|
40
|
+
return None
|
|
41
|
+
match = re.search(r"(\d+)", raw)
|
|
42
|
+
if not match:
|
|
43
|
+
return None
|
|
44
|
+
return max(0, min(100, int(match.group(1))))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_doc(repo_root: Path, kind: str, path: Path) -> LogicsDoc:
|
|
48
|
+
content = path.read_text(encoding="utf-8")
|
|
49
|
+
title = "(missing title)"
|
|
50
|
+
status: str | None = None
|
|
51
|
+
progress: int | None = None
|
|
52
|
+
for line in content.splitlines():
|
|
53
|
+
if line.startswith("## "):
|
|
54
|
+
heading = line.removeprefix("## ").strip()
|
|
55
|
+
if " - " in heading:
|
|
56
|
+
_, title = heading.split(" - ", 1)
|
|
57
|
+
else:
|
|
58
|
+
title = heading
|
|
59
|
+
continue
|
|
60
|
+
if line.startswith("> Status:"):
|
|
61
|
+
status = line.split(":", 1)[1].strip()
|
|
62
|
+
continue
|
|
63
|
+
if line.startswith("> Progress:"):
|
|
64
|
+
progress = _progress_value(line.split(":", 1)[1].strip())
|
|
65
|
+
continue
|
|
66
|
+
return LogicsDoc(
|
|
67
|
+
kind=kind,
|
|
68
|
+
path=path,
|
|
69
|
+
rel_path=path.relative_to(repo_root).as_posix(),
|
|
70
|
+
ref=path.stem,
|
|
71
|
+
title=title,
|
|
72
|
+
status=status,
|
|
73
|
+
progress=progress,
|
|
74
|
+
content=content,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def collect_logics_docs(repo_root: Path, *, kinds: tuple[str, ...] = WORKFLOW_KINDS + COMPANION_KINDS) -> list[LogicsDoc]:
|
|
79
|
+
docs: list[LogicsDoc] = []
|
|
80
|
+
for kind, directory in _doc_dirs(repo_root).items():
|
|
81
|
+
if kind not in kinds or not directory.is_dir():
|
|
82
|
+
continue
|
|
83
|
+
for path in sorted(directory.glob("*.md")):
|
|
84
|
+
docs.append(_parse_doc(repo_root, kind, path))
|
|
85
|
+
return docs
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _status_counts(docs: list[LogicsDoc]) -> dict[str, dict[str, int]]:
|
|
89
|
+
counts: dict[str, dict[str, int]] = {}
|
|
90
|
+
for doc in docs:
|
|
91
|
+
status = doc.status or "(missing)"
|
|
92
|
+
counts.setdefault(doc.kind, {})
|
|
93
|
+
counts[doc.kind][status] = counts[doc.kind].get(status, 0) + 1
|
|
94
|
+
return counts
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _doc_summary(doc: LogicsDoc) -> dict[str, object]:
|
|
98
|
+
return {
|
|
99
|
+
"ref": doc.ref,
|
|
100
|
+
"title": doc.title,
|
|
101
|
+
"kind": doc.kind,
|
|
102
|
+
"status": doc.status,
|
|
103
|
+
"progress": doc.progress,
|
|
104
|
+
"path": doc.rel_path,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def status_payload(repo_root: Path, *, limit: int = 10) -> dict[str, object]:
|
|
109
|
+
docs = collect_logics_docs(repo_root, kinds=WORKFLOW_KINDS)
|
|
110
|
+
open_docs = [doc for doc in docs if doc.status not in CLOSED_STATUSES]
|
|
111
|
+
active_tasks = [
|
|
112
|
+
doc
|
|
113
|
+
for doc in open_docs
|
|
114
|
+
if doc.kind == "task" and doc.status in {"Ready", "In progress", "Blocked"}
|
|
115
|
+
]
|
|
116
|
+
ready_backlog = [
|
|
117
|
+
doc
|
|
118
|
+
for doc in open_docs
|
|
119
|
+
if doc.kind == "backlog" and doc.status in {"Ready", "In progress", "Blocked"}
|
|
120
|
+
]
|
|
121
|
+
task_text = "\n".join(doc.content for doc in docs if doc.kind == "task")
|
|
122
|
+
backlog_without_task = [doc for doc in ready_backlog if doc.ref not in task_text]
|
|
123
|
+
draft_requests = [doc for doc in open_docs if doc.kind == "request" and doc.status == "Draft"]
|
|
124
|
+
blocked_docs = [doc for doc in open_docs if doc.status == "Blocked"]
|
|
125
|
+
|
|
126
|
+
next_actions: list[str] = []
|
|
127
|
+
if blocked_docs:
|
|
128
|
+
next_actions.append(f"Review {len(blocked_docs)} blocked doc(s).")
|
|
129
|
+
if active_tasks:
|
|
130
|
+
next_actions.append(f"Continue or finish {len(active_tasks)} active task(s).")
|
|
131
|
+
if backlog_without_task:
|
|
132
|
+
next_actions.append(f"Promote {len(backlog_without_task)} ready backlog item(s) without detected task links.")
|
|
133
|
+
if draft_requests:
|
|
134
|
+
next_actions.append(f"Groom {len(draft_requests)} draft request(s).")
|
|
135
|
+
if not next_actions:
|
|
136
|
+
next_actions.append("No open workflow action detected.")
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
"ok": True,
|
|
140
|
+
"counts": _status_counts(docs),
|
|
141
|
+
"open_count": len(open_docs),
|
|
142
|
+
"active_tasks": [_doc_summary(doc) for doc in active_tasks[:limit]],
|
|
143
|
+
"backlog_without_task": [_doc_summary(doc) for doc in backlog_without_task[:limit]],
|
|
144
|
+
"draft_requests": [_doc_summary(doc) for doc in draft_requests[:limit]],
|
|
145
|
+
"blocked_docs": [_doc_summary(doc) for doc in blocked_docs[:limit]],
|
|
146
|
+
"next_actions": next_actions,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def render_status(repo_root: Path, *, output_format: str = "text", limit: int = 10) -> str:
|
|
151
|
+
payload = status_payload(repo_root, limit=limit)
|
|
152
|
+
if output_format == "json":
|
|
153
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
154
|
+
|
|
155
|
+
lines = [
|
|
156
|
+
"Logics status:",
|
|
157
|
+
f"- open workflow docs: {payload['open_count']}",
|
|
158
|
+
"- next actions:",
|
|
159
|
+
]
|
|
160
|
+
lines.extend(f" - {action}" for action in payload["next_actions"])
|
|
161
|
+
for key, label in (
|
|
162
|
+
("active_tasks", "Active tasks"),
|
|
163
|
+
("backlog_without_task", "Backlog without detected task"),
|
|
164
|
+
("draft_requests", "Draft requests"),
|
|
165
|
+
("blocked_docs", "Blocked docs"),
|
|
166
|
+
):
|
|
167
|
+
items = payload[key]
|
|
168
|
+
if not items:
|
|
169
|
+
continue
|
|
170
|
+
lines.append(f"- {label}:")
|
|
171
|
+
for item in items:
|
|
172
|
+
lines.append(f" - {item['ref']} [{item['status']}]: {item['title']}")
|
|
173
|
+
return "\n".join(lines)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def health_payload(repo_root: Path, *, limit: int = 10) -> dict[str, object]:
|
|
177
|
+
docs = collect_logics_docs(repo_root, kinds=WORKFLOW_KINDS + COMPANION_KINDS)
|
|
178
|
+
workflow_docs = [doc for doc in docs if doc.kind in WORKFLOW_KINDS]
|
|
179
|
+
missing_status = [doc for doc in docs if not doc.status]
|
|
180
|
+
done_without_full_progress = [
|
|
181
|
+
doc for doc in workflow_docs if doc.status == "Done" and doc.kind in {"backlog", "task"} and doc.progress != 100
|
|
182
|
+
]
|
|
183
|
+
complete_progress_not_done = [
|
|
184
|
+
doc for doc in workflow_docs if doc.progress == 100 and doc.status not in CLOSED_STATUSES
|
|
185
|
+
]
|
|
186
|
+
blocked_docs = [doc for doc in workflow_docs if doc.status == "Blocked"]
|
|
187
|
+
open_docs = [doc for doc in workflow_docs if doc.status not in CLOSED_STATUSES]
|
|
188
|
+
|
|
189
|
+
task_text = "\n".join(doc.content for doc in workflow_docs if doc.kind == "task")
|
|
190
|
+
backlog_without_task = [
|
|
191
|
+
doc
|
|
192
|
+
for doc in open_docs
|
|
193
|
+
if doc.kind == "backlog" and doc.status in {"Ready", "In progress", "Blocked"} and doc.ref not in task_text
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
issue_groups = {
|
|
197
|
+
"missing_status": missing_status,
|
|
198
|
+
"done_without_full_progress": done_without_full_progress,
|
|
199
|
+
"complete_progress_not_done": complete_progress_not_done,
|
|
200
|
+
"blocked_docs": blocked_docs,
|
|
201
|
+
"backlog_without_task": backlog_without_task,
|
|
202
|
+
}
|
|
203
|
+
issue_count = sum(len(items) for items in issue_groups.values())
|
|
204
|
+
return {
|
|
205
|
+
"ok": issue_count == 0,
|
|
206
|
+
"doc_count": len(docs),
|
|
207
|
+
"workflow_doc_count": len(workflow_docs),
|
|
208
|
+
"open_workflow_count": len(open_docs),
|
|
209
|
+
"counts": _status_counts(docs),
|
|
210
|
+
"issue_count": issue_count,
|
|
211
|
+
"issues": {key: [_doc_summary(doc) for doc in items[:limit]] for key, items in issue_groups.items()},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def render_health(repo_root: Path, *, output_format: str = "text", limit: int = 10) -> str:
|
|
216
|
+
payload = health_payload(repo_root, limit=limit)
|
|
217
|
+
if output_format == "json":
|
|
218
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
219
|
+
|
|
220
|
+
lines = [
|
|
221
|
+
"Logics health:",
|
|
222
|
+
f"- docs: {payload['doc_count']}",
|
|
223
|
+
f"- workflow docs: {payload['workflow_doc_count']}",
|
|
224
|
+
f"- open workflow docs: {payload['open_workflow_count']}",
|
|
225
|
+
f"- issue signals: {payload['issue_count']}",
|
|
226
|
+
]
|
|
227
|
+
issues = payload["issues"]
|
|
228
|
+
for key, items in issues.items():
|
|
229
|
+
if not items:
|
|
230
|
+
continue
|
|
231
|
+
label = key.replace("_", " ")
|
|
232
|
+
lines.append(f"- {label}:")
|
|
233
|
+
for item in items:
|
|
234
|
+
lines.append(f" - {item['ref']} [{item['status']}]: {item['title']}")
|
|
235
|
+
return "\n".join(lines)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _slug_command_title(text: str) -> str:
|
|
239
|
+
cleaned = re.sub(r"`([^`]+)`", r"\1", text)
|
|
240
|
+
cleaned = re.sub(r"[*_]+", "", cleaned)
|
|
241
|
+
cleaned = re.sub(r"\s+", " ", cleaned.strip(" .:-"))
|
|
242
|
+
if len(cleaned) > 96:
|
|
243
|
+
cleaned = cleaned[:93].rstrip(" ,.;:") + "..."
|
|
244
|
+
return cleaned[:1].upper() + cleaned[1:] if cleaned else "Follow up"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _is_actionable_followup(text: str) -> bool:
|
|
248
|
+
normalized = re.sub(r"\s+", " ", text.strip(" .")).lower()
|
|
249
|
+
if normalized in {"none", "n/a", "not needed", "no follow-up"}:
|
|
250
|
+
return False
|
|
251
|
+
if normalized.startswith("no ") and "follow-up" in normalized:
|
|
252
|
+
return False
|
|
253
|
+
if normalized.startswith("no ") and "is required" in normalized:
|
|
254
|
+
return False
|
|
255
|
+
if "no new adr is required" in normalized:
|
|
256
|
+
return False
|
|
257
|
+
if "no new architecture decision" in normalized:
|
|
258
|
+
return False
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def followups_payload(
|
|
263
|
+
repo_root: Path,
|
|
264
|
+
*,
|
|
265
|
+
limit: int = 50,
|
|
266
|
+
source_kind: str = "all",
|
|
267
|
+
include_closed: bool = False,
|
|
268
|
+
closed_only: bool = False,
|
|
269
|
+
) -> dict[str, object]:
|
|
270
|
+
docs = collect_logics_docs(repo_root, kinds=WORKFLOW_KINDS + COMPANION_KINDS)
|
|
271
|
+
if source_kind != "all":
|
|
272
|
+
docs = [doc for doc in docs if doc.kind == source_kind]
|
|
273
|
+
if closed_only:
|
|
274
|
+
docs = [doc for doc in docs if doc.status in CLOSED_STATUSES]
|
|
275
|
+
elif not include_closed:
|
|
276
|
+
docs = [doc for doc in docs if doc.status not in CLOSED_STATUSES]
|
|
277
|
+
followups: list[dict[str, object]] = []
|
|
278
|
+
patterns = ("Follow-up area:", "Product follow-up:", "Architecture follow-up:")
|
|
279
|
+
for doc in docs:
|
|
280
|
+
for index, line in enumerate(doc.content.splitlines(), start=1):
|
|
281
|
+
stripped = line.strip().lstrip("- ").strip()
|
|
282
|
+
matched = next((pattern for pattern in patterns if stripped.startswith(pattern)), None)
|
|
283
|
+
if not matched:
|
|
284
|
+
continue
|
|
285
|
+
text = stripped.removeprefix(matched).strip()
|
|
286
|
+
if not _is_actionable_followup(text):
|
|
287
|
+
continue
|
|
288
|
+
title = _slug_command_title(text)
|
|
289
|
+
quoted_title = shlex.quote(title)
|
|
290
|
+
followups.append(
|
|
291
|
+
{
|
|
292
|
+
"source_ref": doc.ref,
|
|
293
|
+
"source_path": doc.rel_path,
|
|
294
|
+
"source_kind": doc.kind,
|
|
295
|
+
"line": index,
|
|
296
|
+
"text": text,
|
|
297
|
+
"suggested_title": title,
|
|
298
|
+
"suggested_command": f"python3 -m logics_manager flow new request --title {quoted_title}",
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
return {
|
|
302
|
+
"ok": True,
|
|
303
|
+
"count": len(followups),
|
|
304
|
+
"returned_count": min(len(followups), limit),
|
|
305
|
+
"filters": {
|
|
306
|
+
"source_kind": source_kind,
|
|
307
|
+
"include_closed": include_closed,
|
|
308
|
+
"closed_only": closed_only,
|
|
309
|
+
},
|
|
310
|
+
"followups": followups[:limit],
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def render_followups(
|
|
315
|
+
repo_root: Path,
|
|
316
|
+
*,
|
|
317
|
+
output_format: str = "text",
|
|
318
|
+
limit: int = 50,
|
|
319
|
+
source_kind: str = "all",
|
|
320
|
+
include_closed: bool = False,
|
|
321
|
+
closed_only: bool = False,
|
|
322
|
+
) -> str:
|
|
323
|
+
payload = followups_payload(
|
|
324
|
+
repo_root,
|
|
325
|
+
limit=limit,
|
|
326
|
+
source_kind=source_kind,
|
|
327
|
+
include_closed=include_closed,
|
|
328
|
+
closed_only=closed_only,
|
|
329
|
+
)
|
|
330
|
+
if output_format == "json":
|
|
331
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
332
|
+
|
|
333
|
+
lines = [f"Logics follow-ups: {payload['count']} found"]
|
|
334
|
+
for item in payload["followups"]:
|
|
335
|
+
lines.append(f"- {item['source_ref']}:{item['line']} {item['text']}")
|
|
336
|
+
lines.append(f" command: {item['suggested_command']}")
|
|
337
|
+
return "\n".join(lines)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _related_ref(content: str, label: str) -> str | None:
|
|
341
|
+
prefix = f"> Related {label}:"
|
|
342
|
+
for line in content.splitlines():
|
|
343
|
+
if not line.startswith(prefix):
|
|
344
|
+
continue
|
|
345
|
+
value = line.split(":", 1)[1].strip()
|
|
346
|
+
normalized = value.strip("`").strip().lower()
|
|
347
|
+
if not normalized or normalized.startswith("(none"):
|
|
348
|
+
return None
|
|
349
|
+
match = re.search(r"`([^`]+)`", value)
|
|
350
|
+
return match.group(1) if match else value
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def product_consistency_payload(repo_root: Path, *, limit: int = 50) -> dict[str, object]:
|
|
355
|
+
docs = collect_logics_docs(repo_root, kinds=WORKFLOW_KINDS + COMPANION_KINDS)
|
|
356
|
+
docs_by_ref = {doc.ref: doc for doc in docs}
|
|
357
|
+
product_docs = [doc for doc in docs if doc.kind == "product"]
|
|
358
|
+
checked_product_docs = [doc for doc in product_docs if doc.status != "Proposed"]
|
|
359
|
+
issues: list[dict[str, object]] = []
|
|
360
|
+
expected = {
|
|
361
|
+
"request": "request",
|
|
362
|
+
"backlog": "backlog",
|
|
363
|
+
"task": "task",
|
|
364
|
+
}
|
|
365
|
+
for doc in checked_product_docs:
|
|
366
|
+
missing_related: list[str] = []
|
|
367
|
+
broken_related: list[dict[str, str]] = []
|
|
368
|
+
for label, expected_kind in expected.items():
|
|
369
|
+
ref = _related_ref(doc.content, label)
|
|
370
|
+
if ref is None:
|
|
371
|
+
missing_related.append(label)
|
|
372
|
+
continue
|
|
373
|
+
target = docs_by_ref.get(ref)
|
|
374
|
+
if target is None:
|
|
375
|
+
broken_related.append({"kind": label, "ref": ref, "reason": "missing"})
|
|
376
|
+
elif target.kind != expected_kind:
|
|
377
|
+
broken_related.append({"kind": label, "ref": ref, "reason": f"expected {expected_kind}, found {target.kind}"})
|
|
378
|
+
if missing_related or broken_related:
|
|
379
|
+
issues.append(
|
|
380
|
+
{
|
|
381
|
+
"ref": doc.ref,
|
|
382
|
+
"title": doc.title,
|
|
383
|
+
"status": doc.status,
|
|
384
|
+
"path": doc.rel_path,
|
|
385
|
+
"missing_related": missing_related,
|
|
386
|
+
"broken_related": broken_related,
|
|
387
|
+
}
|
|
388
|
+
)
|
|
389
|
+
return {
|
|
390
|
+
"ok": not issues,
|
|
391
|
+
"product_count": len(product_docs),
|
|
392
|
+
"checked_product_count": len(checked_product_docs),
|
|
393
|
+
"skipped_product_count": len(product_docs) - len(checked_product_docs),
|
|
394
|
+
"issue_count": len(issues),
|
|
395
|
+
"issues": issues[:limit],
|
|
396
|
+
"truncated": len(issues) > limit,
|
|
397
|
+
"limit": limit,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def render_product_consistency(repo_root: Path, *, output_format: str = "text", limit: int = 50) -> str:
|
|
402
|
+
payload = product_consistency_payload(repo_root, limit=limit)
|
|
403
|
+
if output_format == "json":
|
|
404
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
405
|
+
|
|
406
|
+
lines = [
|
|
407
|
+
"Product consistency:",
|
|
408
|
+
f"- product briefs: {payload['product_count']}",
|
|
409
|
+
f"- issue signals: {payload['issue_count']}",
|
|
410
|
+
]
|
|
411
|
+
for issue in payload["issues"]:
|
|
412
|
+
details: list[str] = []
|
|
413
|
+
if issue["missing_related"]:
|
|
414
|
+
details.append("missing " + ", ".join(issue["missing_related"]))
|
|
415
|
+
if issue["broken_related"]:
|
|
416
|
+
details.append("broken " + ", ".join(item["ref"] for item in issue["broken_related"]))
|
|
417
|
+
lines.append(f"- {issue['ref']} [{issue['status']}]: {'; '.join(details)}")
|
|
418
|
+
return "\n".join(lines)
|
package/logics_manager/mcp.py
CHANGED
|
@@ -21,6 +21,7 @@ from urllib.request import Request, urlopen
|
|
|
21
21
|
from .audit import audit_payload
|
|
22
22
|
from .config import ConfigError, find_repo_root
|
|
23
23
|
from .flow import flow_list_payload
|
|
24
|
+
from .insights import followups_payload, health_payload, product_consistency_payload, status_payload
|
|
24
25
|
from .lint import expected_workflow_mermaid_signature, lint_payload
|
|
25
26
|
from .sync import append_workflow_note_payload, build_context_pack_payload, list_logics_docs_payload, read_logics_doc_payload, search_logics_docs_payload, update_workflow_indicators_payload
|
|
26
27
|
|
|
@@ -188,6 +189,37 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|
|
188
189
|
),
|
|
189
190
|
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
190
191
|
},
|
|
192
|
+
{
|
|
193
|
+
"name": "get_logics_status",
|
|
194
|
+
"description": "Summarize open Logics workflow docs and next actions.",
|
|
195
|
+
"inputSchema": _tool_schema({"limit": {"type": "integer"}}),
|
|
196
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"name": "get_logics_health",
|
|
200
|
+
"description": "Show Logics workflow health counts and issue signals.",
|
|
201
|
+
"inputSchema": _tool_schema({"limit": {"type": "integer"}}),
|
|
202
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"name": "list_logics_followups",
|
|
206
|
+
"description": "List actionable Logics follow-up areas with request creation commands.",
|
|
207
|
+
"inputSchema": _tool_schema(
|
|
208
|
+
{
|
|
209
|
+
"source_kind": {"type": "string", "enum": ["all", "request", "backlog", "task", "product", "architecture"]},
|
|
210
|
+
"include_closed": {"type": "boolean"},
|
|
211
|
+
"closed_only": {"type": "boolean"},
|
|
212
|
+
"limit": {"type": "integer"},
|
|
213
|
+
}
|
|
214
|
+
),
|
|
215
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"name": "check_product_consistency",
|
|
219
|
+
"description": "Check product brief lineage links for active and validated product docs.",
|
|
220
|
+
"inputSchema": _tool_schema({"limit": {"type": "integer"}}),
|
|
221
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
222
|
+
},
|
|
191
223
|
{
|
|
192
224
|
"name": "finish_task",
|
|
193
225
|
"description": "Finish a Logics task through the canonical flow finish task command.",
|
|
@@ -811,6 +843,24 @@ def call_tool(name: str, arguments: dict[str, Any] | None = None, *, repo_root:
|
|
|
811
843
|
except SystemExit as exc:
|
|
812
844
|
raise _mcp_read_error(exc) from exc
|
|
813
845
|
return {"ok": True, **payload}
|
|
846
|
+
if name == "get_logics_status":
|
|
847
|
+
return status_payload(root, limit=_bounded_int(args.get("limit"), default=10, maximum=100))
|
|
848
|
+
if name == "get_logics_health":
|
|
849
|
+
return health_payload(root, limit=_bounded_int(args.get("limit"), default=10, maximum=100))
|
|
850
|
+
if name == "list_logics_followups":
|
|
851
|
+
include_closed = bool(args.get("include_closed", False))
|
|
852
|
+
closed_only = bool(args.get("closed_only", False))
|
|
853
|
+
if include_closed and closed_only:
|
|
854
|
+
raise McpToolError("invalid_argument_value", "include_closed and closed_only are mutually exclusive.", details={"arguments": ["include_closed", "closed_only"]})
|
|
855
|
+
return followups_payload(
|
|
856
|
+
root,
|
|
857
|
+
limit=_bounded_int(args.get("limit"), default=50, maximum=200),
|
|
858
|
+
source_kind=str(args.get("source_kind") or "all"),
|
|
859
|
+
include_closed=include_closed,
|
|
860
|
+
closed_only=closed_only,
|
|
861
|
+
)
|
|
862
|
+
if name == "check_product_consistency":
|
|
863
|
+
return product_consistency_payload(root, limit=_bounded_int(args.get("limit"), default=50, maximum=200))
|
|
814
864
|
if name == "finish_task":
|
|
815
865
|
rel_path = _relative_path(root, str(args.get("task_path") or ""), ("logics/tasks",))
|
|
816
866
|
dry_run = bool(args.get("dry_run", False))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def ensure_relative_to(path: Path, root: Path, *, label: str = "path") -> Path:
|
|
7
|
+
try:
|
|
8
|
+
return path.resolve().relative_to(root.resolve())
|
|
9
|
+
except ValueError as exc:
|
|
10
|
+
raise SystemExit(f"Unsupported {label}: `{path}` is outside the repository.") from exc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_repo_output_path(repo_root: Path, raw_path: str, *, label: str = "--out") -> tuple[Path, str]:
|
|
14
|
+
candidate = Path(raw_path)
|
|
15
|
+
if candidate.is_absolute() or any(part == ".." for part in candidate.parts):
|
|
16
|
+
raise SystemExit(f"Unsupported {label} path `{raw_path}`. Use a repo-relative path inside the repository.")
|
|
17
|
+
resolved = (repo_root / candidate).resolve()
|
|
18
|
+
relative = ensure_relative_to(resolved, repo_root, label=label)
|
|
19
|
+
return resolved, relative.as_posix()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_repo_config_path(repo_root: Path, raw_path: str, *, label: str = "configured path") -> tuple[Path, str]:
|
|
23
|
+
candidate = Path(raw_path)
|
|
24
|
+
if any(part == ".." for part in candidate.parts):
|
|
25
|
+
raise SystemExit(f"Unsupported {label} path `{raw_path}`. Use a repo-relative path or absolute path inside the repository.")
|
|
26
|
+
resolved = candidate.resolve() if candidate.is_absolute() else (repo_root / candidate).resolve()
|
|
27
|
+
try:
|
|
28
|
+
relative = ensure_relative_to(resolved, repo_root, label=label)
|
|
29
|
+
except SystemExit as exc:
|
|
30
|
+
raise SystemExit(f"Unsupported {label} path `{raw_path}`. Use a repo-relative path or absolute path inside the repository.") from exc
|
|
31
|
+
return resolved, relative.as_posix()
|
package/logics_manager/sync.py
CHANGED
|
@@ -10,6 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
from .config import find_repo_root
|
|
12
12
|
from .lint import expected_workflow_mermaid_signature
|
|
13
|
+
from .path_utils import resolve_repo_output_path
|
|
13
14
|
from .termstyle import colorize_help
|
|
14
15
|
|
|
15
16
|
|
|
@@ -576,9 +577,10 @@ def append_workflow_note_payload(repo_root: Path, source: str, *, note_kind: str
|
|
|
576
577
|
changed = False
|
|
577
578
|
else:
|
|
578
579
|
lines.insert(insert_at, bullet)
|
|
580
|
+
mermaid_signature_refreshed = False
|
|
579
581
|
if changed and not dry_run:
|
|
580
582
|
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
581
|
-
refresh_workflow_mermaid_signature_file(path, kind, dry_run=False, repo_root=repo_root)
|
|
583
|
+
mermaid_signature_refreshed = refresh_workflow_mermaid_signature_file(path, kind, dry_run=False, repo_root=repo_root)
|
|
582
584
|
return {
|
|
583
585
|
"path": path.relative_to(repo_root).as_posix(),
|
|
584
586
|
"ref": path.stem,
|
|
@@ -586,6 +588,7 @@ def append_workflow_note_payload(repo_root: Path, source: str, *, note_kind: str
|
|
|
586
588
|
"section": section,
|
|
587
589
|
"text": cleaned,
|
|
588
590
|
"changed": changed,
|
|
591
|
+
"mermaid_signature_refreshed": mermaid_signature_refreshed,
|
|
589
592
|
"dry_run": dry_run,
|
|
590
593
|
}
|
|
591
594
|
|
|
@@ -689,7 +692,7 @@ def refresh_workflow_mermaid_signature_file(path: Path, kind: str, dry_run: bool
|
|
|
689
692
|
return True
|
|
690
693
|
|
|
691
694
|
|
|
692
|
-
def _close_eligible_requests(repo_root: Path, dry_run: bool) -> tuple[int, int]:
|
|
695
|
+
def _close_eligible_requests(repo_root: Path, dry_run: bool, *, quiet: bool = False) -> tuple[int, int]:
|
|
693
696
|
request_dir = repo_root / DOC_KINDS["request"]["directory"]
|
|
694
697
|
closed = 0
|
|
695
698
|
scanned = 0
|
|
@@ -703,7 +706,8 @@ def _close_eligible_requests(repo_root: Path, dry_run: bool) -> tuple[int, int]:
|
|
|
703
706
|
continue
|
|
704
707
|
if all(_is_doc_done(item_path, "backlog") for item_path in linked_items):
|
|
705
708
|
_close_doc(request_path, "request", dry_run)
|
|
706
|
-
|
|
709
|
+
if not quiet:
|
|
710
|
+
print(f"Auto-closed request {request_ref} (all linked backlog items are done).")
|
|
707
711
|
closed += 1
|
|
708
712
|
return scanned, closed
|
|
709
713
|
|
|
@@ -1030,7 +1034,7 @@ def _print_help(text: str) -> None:
|
|
|
1030
1034
|
|
|
1031
1035
|
def cmd_close_eligible_requests(args: argparse.Namespace) -> dict[str, object]:
|
|
1032
1036
|
repo_root = _find_repo_root(Path.cwd())
|
|
1033
|
-
scanned, closed = _close_eligible_requests(repo_root, args.dry_run)
|
|
1037
|
+
scanned, closed = _close_eligible_requests(repo_root, args.dry_run, quiet=args.format == "json")
|
|
1034
1038
|
payload = {
|
|
1035
1039
|
"command": "sync",
|
|
1036
1040
|
"kind": "close-eligible-requests",
|
|
@@ -1161,6 +1165,8 @@ def cmd_append_note(args: argparse.Namespace) -> dict[str, object]:
|
|
|
1161
1165
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1162
1166
|
else:
|
|
1163
1167
|
print(f"Appended {args.section} note to {payload['path']} (changed: {payload['changed']}).")
|
|
1168
|
+
if payload.get("mermaid_signature_refreshed"):
|
|
1169
|
+
print("- Mermaid signature refreshed.")
|
|
1164
1170
|
return {"command": "sync", "kind": "append-note", "repo_root": repo_root.as_posix(), **payload}
|
|
1165
1171
|
|
|
1166
1172
|
|
|
@@ -1168,13 +1174,16 @@ def cmd_context_pack(args: argparse.Namespace) -> dict[str, object]:
|
|
|
1168
1174
|
repo_root = _find_repo_root(Path.cwd())
|
|
1169
1175
|
payload = _build_context_pack(repo_root, args.ref, mode=args.mode, profile=args.profile, config=None)
|
|
1170
1176
|
if args.out:
|
|
1171
|
-
out_path = (repo_root
|
|
1177
|
+
out_path, output_path = resolve_repo_output_path(repo_root, args.out)
|
|
1172
1178
|
serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
1173
|
-
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1174
1179
|
if not args.dry_run:
|
|
1180
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1175
1181
|
out_path.write_text(serialized, encoding="utf-8")
|
|
1176
|
-
|
|
1177
|
-
|
|
1182
|
+
payload["output_path"] = output_path
|
|
1183
|
+
if args.format == "json":
|
|
1184
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1185
|
+
else:
|
|
1186
|
+
print(f"Wrote {output_path}")
|
|
1178
1187
|
else:
|
|
1179
1188
|
if args.format == "json":
|
|
1180
1189
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
@@ -1189,13 +1198,16 @@ def cmd_export_graph(args: argparse.Namespace) -> dict[str, object]:
|
|
|
1189
1198
|
payload = _graph_payload(repo_root, config=None)
|
|
1190
1199
|
payload["repo_root"] = repo_root.as_posix()
|
|
1191
1200
|
if args.out:
|
|
1192
|
-
out_path = (repo_root
|
|
1201
|
+
out_path, output_path = resolve_repo_output_path(repo_root, args.out)
|
|
1193
1202
|
serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
1194
|
-
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1195
1203
|
if not args.dry_run:
|
|
1204
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1196
1205
|
out_path.write_text(serialized, encoding="utf-8")
|
|
1197
|
-
|
|
1198
|
-
|
|
1206
|
+
payload["output_path"] = output_path
|
|
1207
|
+
if args.format == "json":
|
|
1208
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1209
|
+
else:
|
|
1210
|
+
print(f"Wrote {output_path}")
|
|
1199
1211
|
else:
|
|
1200
1212
|
if args.format == "json":
|
|
1201
1213
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@grifhinz/logics-manager",
|
|
3
3
|
"displayName": "Logics Orchestrator",
|
|
4
4
|
"description": "Visual orchestration for Logics workflows inside VS Code.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.2.0",
|
|
6
6
|
"publisher": "cdx-logics",
|
|
7
7
|
"icon": "media/icon.png",
|
|
8
8
|
"repository": {
|