@grifhinz/logics-manager 2.1.2 → 2.3.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.
Files changed (49) hide show
  1. package/README.md +106 -4
  2. package/VERSION +1 -1
  3. package/clients/README.md +9 -0
  4. package/clients/shared-web/media/css/board.css +658 -0
  5. package/clients/shared-web/media/css/details.css +457 -0
  6. package/clients/shared-web/media/css/layout.css +123 -0
  7. package/clients/shared-web/media/css/toolbar.css +576 -0
  8. package/clients/shared-web/media/harnessApi.js +324 -0
  9. package/clients/shared-web/media/hostApi.js +213 -0
  10. package/clients/shared-web/media/hostApiContract.js +55 -0
  11. package/clients/shared-web/media/icon.png +0 -0
  12. package/clients/shared-web/media/layoutController.js +246 -0
  13. package/clients/shared-web/media/logics.svg +7 -0
  14. package/clients/shared-web/media/logicsModel.js +910 -0
  15. package/clients/shared-web/media/main.css +112 -0
  16. package/clients/shared-web/media/main.js +3 -0
  17. package/clients/shared-web/media/mainApp.js +1005 -0
  18. package/clients/shared-web/media/mainCore.js +604 -0
  19. package/clients/shared-web/media/mainInteractionHandlers.js +324 -0
  20. package/clients/shared-web/media/mainInteractions.js +378 -0
  21. package/clients/shared-web/media/renderBoard.js +3 -0
  22. package/clients/shared-web/media/renderBoardApp.js +1339 -0
  23. package/clients/shared-web/media/renderDetails.js +685 -0
  24. package/clients/shared-web/media/renderMarkdown.js +449 -0
  25. package/clients/shared-web/media/toolsPanelLayout.js +172 -0
  26. package/clients/shared-web/media/uiStatus.js +54 -0
  27. package/clients/shared-web/media/webviewChrome.js +405 -0
  28. package/clients/shared-web/media/webviewPersistence.js +116 -0
  29. package/clients/shared-web/media/webviewSelectors.js +491 -0
  30. package/clients/viewer/README.md +5 -0
  31. package/clients/viewer/browser-host.js +847 -0
  32. package/clients/viewer/index.html +237 -0
  33. package/clients/viewer/viewer.css +433 -0
  34. package/logics_manager/assist.py +94 -63
  35. package/logics_manager/assist_handoff.py +132 -0
  36. package/logics_manager/assist_surface.py +38 -0
  37. package/logics_manager/cli.py +152 -12
  38. package/logics_manager/cli_output.py +18 -0
  39. package/logics_manager/flow.py +1360 -84
  40. package/logics_manager/flow_evidence.py +63 -0
  41. package/logics_manager/index.py +3 -7
  42. package/logics_manager/insights.py +418 -0
  43. package/logics_manager/mcp.py +50 -0
  44. package/logics_manager/path_utils.py +31 -0
  45. package/logics_manager/sync.py +24 -12
  46. package/logics_manager/update_check.py +138 -0
  47. package/logics_manager/viewer.py +533 -0
  48. package/package.json +12 -6
  49. package/pyproject.toml +1 -1
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+ import re
5
+
6
+
7
+ def section_lines(lines: list[str], heading: str) -> list[str]:
8
+ start_idx = None
9
+ target = heading.strip().lower()
10
+ for idx, line in enumerate(lines):
11
+ if line.startswith("# ") and line[2:].strip().lower() == target:
12
+ start_idx = idx + 1
13
+ break
14
+ if start_idx is None:
15
+ return []
16
+ out: list[str] = []
17
+ for idx in range(start_idx, len(lines)):
18
+ line = lines[idx]
19
+ if line.startswith("# "):
20
+ break
21
+ out.append(line)
22
+ return out
23
+
24
+
25
+ def has_validation_evidence(text: str) -> bool:
26
+ concrete_ok_context = ("lint", "audit", "test", "pytest", "npm", "ci", "coverage", "smoke", "package")
27
+ invalid_markers = ("...", "todo", "tbd", "pending", "needs ", "need ", "not ok", "failed", "failure", "failing")
28
+ for line in section_lines(text.splitlines(), "Validation"):
29
+ stripped = line.strip()
30
+ if not stripped.startswith("- "):
31
+ continue
32
+ value = stripped[2:].strip().lower()
33
+ if not value or value.startswith("run `") or value.startswith("run the "):
34
+ continue
35
+ if any(marker in value for marker in invalid_markers):
36
+ continue
37
+ if "command:" in value and "result:" in value and ("date:" in value or "session:" in value):
38
+ result_match = re.search(r"\bresult:\s*([^|,;]+)", value)
39
+ result = result_match.group(1).strip() if result_match else ""
40
+ if result in {"pass", "passed", "ok", "success", "succeeded"}:
41
+ return True
42
+ if any(marker in value for marker in ("pass", "validated", "verified", "verification", "regression")):
43
+ return True
44
+ if "ok" in value and any(marker in value for marker in concrete_ok_context):
45
+ return True
46
+ return False
47
+
48
+
49
+ def has_ac_proof(text: str, ac_id: str) -> bool:
50
+ upper = text.upper()
51
+ return ac_id.upper() in upper and "proof:" in text.lower()
52
+
53
+
54
+ def structured_validation_line(command: str, result: str, note: str | None) -> str:
55
+ normalized_result = result.strip().lower() or "passed"
56
+ parts = [
57
+ f"command: `{command.strip()}`",
58
+ f"result: {normalized_result}",
59
+ f"date: {date.today().isoformat()}",
60
+ ]
61
+ if note and note.strip():
62
+ parts.append(f"note: {note.strip()}")
63
+ return " | ".join(parts)
@@ -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 / out).resolve()
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": printable.as_posix(),
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)
@@ -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()