@grifhinz/logics-manager 2.1.1 → 2.1.2
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 +34 -2
- package/VERSION +1 -1
- package/logics_manager/audit.py +72 -18
- package/logics_manager/cli.py +31 -50
- package/logics_manager/lint.py +21 -7
- package/logics_manager/mcp.py +335 -27
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/scripts/npm/logics-manager.mjs +13 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/AlexAgo83/logics-manager/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
|
-

|
|
6
6
|

|
|
7
7
|

|
|
8
8
|

|
|
@@ -167,6 +167,25 @@ LOGICS_MCP_BEARER_TOKEN="$(openssl rand -hex 32)" python3 -m logics_manager mcp
|
|
|
167
167
|
|
|
168
168
|
`POST /mcp` accepts `Authorization: Bearer <token>` when `LOGICS_MCP_BEARER_TOKEN` or `--bearer-token` is set. Keep `/health` unauthenticated for smoke checks, but do not expose `/mcp` publicly without a bearer token.
|
|
169
169
|
|
|
170
|
+
Start the local server and a temporary `localtunnel` session in one command:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
python3 -m logics_manager mcp tunnel --repo-root . --port 8765
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
For short-lived live debugging only, run without bearer auth:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
python3 -m logics_manager mcp tunnel --repo-root . --port 8765 --no-bearer
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
During project development, the same commands can be run through the repository binary:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
node scripts/npm/logics-manager.mjs mcp tunnel --repo-root . --port 8765
|
|
186
|
+
node scripts/npm/logics-manager.mjs mcp tunnel --repo-root . --port 8765 --no-bearer
|
|
187
|
+
```
|
|
188
|
+
|
|
170
189
|
Generate a local connector plan:
|
|
171
190
|
|
|
172
191
|
```bash
|
|
@@ -179,7 +198,13 @@ With an HTTPS tunnel URL:
|
|
|
179
198
|
python3 -m logics_manager mcp connect --repo-root . --public-url https://example-tunnel.example --check
|
|
180
199
|
```
|
|
181
200
|
|
|
182
|
-
|
|
201
|
+
For a no-bearer plan:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
python3 -m logics_manager mcp connect --repo-root . --public-url https://example-tunnel.example --no-bearer --check
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The connector plan prints the bearer token when used, server command, tunnel target, assistant connector URL, auth mode, auth header, smoke checks, warnings, and cleanup steps.
|
|
183
208
|
|
|
184
209
|
## Assistant Model
|
|
185
210
|
|
|
@@ -377,10 +402,17 @@ If the current plugin version is already published, `logics-manager assist next-
|
|
|
377
402
|
- VSIX package validation: `npm run package:ci`
|
|
378
403
|
- Logics docs lint: `npm run lint:logics`
|
|
379
404
|
- Logics workflow audit + docs lint: `npm run audit:logics`
|
|
405
|
+
- Strict Logics governance audit: `npm run audit:logics:strict`
|
|
380
406
|
- Fast extension-focused local check: `npm run ci:fast`
|
|
381
407
|
- Full CI-equivalent local check: `npm run ci:check`
|
|
382
408
|
- Security audit policy gate: `npm run audit:ci`
|
|
383
409
|
|
|
410
|
+
`npm run audit:logics` uses the default active-work profile. It blocks correctness and traceability failures, but reports early companion-doc polish such as missing overview Mermaid diagrams as warnings so drafting and agent handoffs can continue.
|
|
411
|
+
|
|
412
|
+
`npm run audit:logics:strict` uses the strict governance profile. Use it before release or governance review when companion docs must be complete and warning-class findings should be resolved.
|
|
413
|
+
|
|
414
|
+
`logics-manager audit --format json` and `logics-manager lint --format json` expose `issue_count`, `warning_count`, `strict_count`, `finding_count`, `can_continue`, and `release_ready`. Agents should treat `issue_count > 0` as blocking active work, and `release_ready: false` as a signal that cleanup remains before release-grade validation.
|
|
415
|
+
|
|
384
416
|
`npm run ci:check` mirrors the blocking repository CI contract, including Logics strict-status lint, request auto-close sync verification, workflow audit, Python tests, CLI smoke checks, TypeScript validation, extension tests, and VSIX packaging.
|
|
385
417
|
|
|
386
418
|
`npm run audit:ci` enforces the repository audit policy locally. It blocks new actionable vulnerabilities and only allows the explicitly documented temporary exceptions tracked in the backlog.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.1.
|
|
1
|
+
2.1.2
|
package/logics_manager/audit.py
CHANGED
|
@@ -107,6 +107,8 @@ class AuditIssue:
|
|
|
107
107
|
code: str
|
|
108
108
|
path: Path | None
|
|
109
109
|
message: str
|
|
110
|
+
severity: str = "blocking"
|
|
111
|
+
repair_command: str | None = None
|
|
110
112
|
|
|
111
113
|
|
|
112
114
|
def _indicator_value(lines: list[str], key: str) -> str | None:
|
|
@@ -167,6 +169,15 @@ def _has_mermaid_block(text: str) -> bool:
|
|
|
167
169
|
return "```mermaid" in text
|
|
168
170
|
|
|
169
171
|
|
|
172
|
+
def _companion_doc_is_mature(doc: "DocMeta") -> bool:
|
|
173
|
+
status = _status_normalized(doc.status)
|
|
174
|
+
if doc.kind.kind == "product":
|
|
175
|
+
return status in {"active", "validated", "archived"}
|
|
176
|
+
if doc.kind.kind == "architecture":
|
|
177
|
+
return status in {"accepted", "superseded", "archived"}
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
170
181
|
def _decision_framing_value(text: str, label: str) -> str | None:
|
|
171
182
|
pattern = re.compile(rf"^\s*-\s*{re.escape(label)}\s*:\s*(.+)\s*$", re.MULTILINE)
|
|
172
183
|
match = pattern.search(text)
|
|
@@ -511,11 +522,11 @@ def _rel(repo_root: Path, path: Path | None) -> str:
|
|
|
511
522
|
|
|
512
523
|
|
|
513
524
|
def _sorted_issues(issues: Iterable[AuditIssue], repo_root: Path) -> list[AuditIssue]:
|
|
514
|
-
unique: dict[tuple[str, str, str], AuditIssue] = {}
|
|
525
|
+
unique: dict[tuple[str, str, str, str], AuditIssue] = {}
|
|
515
526
|
for issue in issues:
|
|
516
|
-
key = (_rel(repo_root, issue.path), issue.code, issue.message)
|
|
527
|
+
key = (issue.severity, _rel(repo_root, issue.path), issue.code, issue.message)
|
|
517
528
|
unique.setdefault(key, issue)
|
|
518
|
-
return sorted(unique.values(), key=lambda issue: (_rel(repo_root, issue.path), issue.code, issue.message))
|
|
529
|
+
return sorted(unique.values(), key=lambda issue: (_rel(repo_root, issue.path), issue.severity, issue.code, issue.message))
|
|
519
530
|
|
|
520
531
|
|
|
521
532
|
def _scan_hybrid_cache_for_credentials(repo_root: Path) -> list[AuditIssue]:
|
|
@@ -584,6 +595,7 @@ def audit_payload(
|
|
|
584
595
|
docs = _apply_scope(all_docs, repo_root, paths or [], refs or [], scope_since)
|
|
585
596
|
|
|
586
597
|
issues: list[AuditIssue] = []
|
|
598
|
+
strict_governance = governance_profile == "strict"
|
|
587
599
|
autofix_targets: dict[Path, set[str]] = {}
|
|
588
600
|
autofix_modified: list[Path] = []
|
|
589
601
|
|
|
@@ -652,20 +664,26 @@ def audit_payload(
|
|
|
652
664
|
for prefix in ("req", "item", "task", "prod", "adr"):
|
|
653
665
|
linked_refs.update(_extract_refs(doc.text, prefix))
|
|
654
666
|
|
|
667
|
+
companion_is_mature = _companion_doc_is_mature(doc)
|
|
668
|
+
|
|
655
669
|
if not any(ref.startswith(("req_", "item_", "task_")) for ref in linked_refs):
|
|
670
|
+
primary_link_severity = "blocking" if strict_governance or companion_is_mature else "warning"
|
|
656
671
|
issues.append(
|
|
657
672
|
AuditIssue(
|
|
658
673
|
code="companion_doc_missing_primary_link",
|
|
659
674
|
path=doc.path,
|
|
660
675
|
message="companion doc has no linked request, backlog item, or task reference",
|
|
676
|
+
severity=primary_link_severity,
|
|
661
677
|
)
|
|
662
678
|
)
|
|
663
679
|
if not _has_mermaid_block(doc.text):
|
|
680
|
+
mermaid_severity = "blocking" if strict_governance or companion_is_mature else "warning"
|
|
664
681
|
issues.append(
|
|
665
682
|
AuditIssue(
|
|
666
683
|
code="companion_doc_missing_mermaid",
|
|
667
684
|
path=doc.path,
|
|
668
685
|
message="companion doc is missing its overview Mermaid diagram",
|
|
686
|
+
severity=mermaid_severity,
|
|
669
687
|
)
|
|
670
688
|
)
|
|
671
689
|
placeholders = COMPANION_PLACEHOLDERS.get(doc.kind.kind, ())
|
|
@@ -850,20 +868,46 @@ def audit_payload(
|
|
|
850
868
|
|
|
851
869
|
by_code: dict[str, int] = {}
|
|
852
870
|
by_path: dict[str, int] = {}
|
|
853
|
-
|
|
871
|
+
by_severity: dict[str, int] = {}
|
|
872
|
+
serialized_findings: list[dict[str, str]] = []
|
|
854
873
|
for issue in sorted_issues:
|
|
855
874
|
rel_path = _rel(repo_root, issue.path)
|
|
856
875
|
by_code[issue.code] = by_code.get(issue.code, 0) + 1
|
|
857
876
|
by_path[rel_path] = by_path.get(rel_path, 0) + 1
|
|
858
|
-
|
|
877
|
+
by_severity[issue.severity] = by_severity.get(issue.severity, 0) + 1
|
|
878
|
+
finding = {"code": issue.code, "path": rel_path, "message": issue.message, "severity": issue.severity}
|
|
879
|
+
if issue.repair_command:
|
|
880
|
+
finding["repair_command"] = issue.repair_command
|
|
881
|
+
serialized_findings.append(finding)
|
|
882
|
+
|
|
883
|
+
blocking_findings = [finding for finding in serialized_findings if finding["severity"] == "blocking"]
|
|
884
|
+
warning_findings = [finding for finding in serialized_findings if finding["severity"] == "warning"]
|
|
885
|
+
strict_findings = [finding for finding in serialized_findings if finding["severity"] == "strict"]
|
|
886
|
+
findings_by_doc: dict[str, list[dict[str, str]]] = {}
|
|
887
|
+
for finding in serialized_findings:
|
|
888
|
+
findings_by_doc.setdefault(finding["path"], []).append(finding)
|
|
889
|
+
issues_by_doc: dict[str, list[dict[str, str]]] = {}
|
|
890
|
+
for finding in blocking_findings:
|
|
891
|
+
issues_by_doc.setdefault(finding["path"], []).append(finding)
|
|
859
892
|
|
|
860
893
|
return {
|
|
861
|
-
"ok": not
|
|
862
|
-
"
|
|
863
|
-
"
|
|
894
|
+
"ok": not blocking_findings,
|
|
895
|
+
"can_continue": not blocking_findings,
|
|
896
|
+
"release_ready": not blocking_findings and not warning_findings and not strict_findings,
|
|
897
|
+
"issue_count": len(blocking_findings),
|
|
898
|
+
"warning_count": len(warning_findings),
|
|
899
|
+
"strict_count": len(strict_findings),
|
|
900
|
+
"finding_count": len(serialized_findings),
|
|
901
|
+
"issues": blocking_findings,
|
|
902
|
+
"warnings": warning_findings,
|
|
903
|
+
"strict": strict_findings,
|
|
904
|
+
"findings": serialized_findings,
|
|
905
|
+
"issues_by_doc": dict(sorted(issues_by_doc.items())),
|
|
906
|
+
"findings_by_doc": dict(sorted(findings_by_doc.items())),
|
|
864
907
|
"counts": {
|
|
865
908
|
"by_code": dict(sorted(by_code.items())),
|
|
866
909
|
"by_path": dict(sorted(by_path.items())),
|
|
910
|
+
"by_severity": dict(sorted(by_severity.items())),
|
|
867
911
|
},
|
|
868
912
|
"autofix": {
|
|
869
913
|
"enabled": autofix_ac_traceability or autofix_structure,
|
|
@@ -909,25 +953,35 @@ def render_audit(
|
|
|
909
953
|
if output_format == "json":
|
|
910
954
|
return json.dumps(payload, indent=2, sort_keys=True)
|
|
911
955
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
956
|
+
if payload["ok"] and (payload["warning_count"] or payload["strict_count"]):
|
|
957
|
+
status_line = "Workflow audit: OK (warnings)"
|
|
958
|
+
else:
|
|
959
|
+
status_line = "Workflow audit: OK" if payload["ok"] else "Workflow audit: FAILED"
|
|
960
|
+
lines = [
|
|
961
|
+
status_line,
|
|
962
|
+
f"Workflow docs inspected: {payload['workflow_doc_count']}",
|
|
963
|
+
f"Blocking issues: {payload['issue_count']}; warnings: {payload['warning_count']}; strict-only findings: {payload['strict_count']}",
|
|
964
|
+
]
|
|
965
|
+
findings = payload["findings"]
|
|
966
|
+
if not findings:
|
|
915
967
|
return "\n".join(lines)
|
|
916
968
|
if not group_by_doc:
|
|
917
|
-
for issue in
|
|
969
|
+
for issue in findings:
|
|
970
|
+
prefix = "WARNING" if issue["severity"] == "warning" else "STRICT" if issue["severity"] == "strict" else "BLOCKING"
|
|
918
971
|
if issue["path"] == "(global)":
|
|
919
|
-
lines.append(f"- [{issue['code']}] {issue['message']}")
|
|
972
|
+
lines.append(f"- {prefix}: [{issue['code']}] {issue['message']}")
|
|
920
973
|
else:
|
|
921
|
-
lines.append(f"- {issue['path']}: [{issue['code']}] {issue['message']}")
|
|
974
|
+
lines.append(f"- {issue['path']}: {prefix}: [{issue['code']}] {issue['message']}")
|
|
922
975
|
return "\n".join(lines)
|
|
923
976
|
|
|
924
977
|
grouped: dict[str, list[dict[str, str]]] = {}
|
|
925
|
-
for issue in
|
|
978
|
+
for issue in findings:
|
|
926
979
|
grouped.setdefault(issue["path"], []).append(issue)
|
|
927
980
|
for rel_path in sorted(grouped):
|
|
928
981
|
lines.append(f"- {rel_path}")
|
|
929
|
-
for issue in sorted(grouped[rel_path], key=lambda item: (item["code"], item["message"])):
|
|
930
|
-
|
|
982
|
+
for issue in sorted(grouped[rel_path], key=lambda item: (item["severity"], item["code"], item["message"])):
|
|
983
|
+
prefix = "WARNING" if issue["severity"] == "warning" else "STRICT" if issue["severity"] == "strict" else "BLOCKING"
|
|
984
|
+
lines.append(f" - {prefix}: [{issue['code']}] {issue['message']}")
|
|
931
985
|
return "\n".join(lines)
|
|
932
986
|
|
|
933
987
|
|
|
@@ -948,7 +1002,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
948
1002
|
parser.add_argument("--since-version", help="Limit the audit to docs with `From version` >= this semantic version.")
|
|
949
1003
|
parser.add_argument("--token-hygiene", action="store_true", help="Enable compact AI context and verbosity checks for workflow docs.")
|
|
950
1004
|
parser.add_argument("--autofix-structure", action="store_true", help="Deterministically repair missing schema metadata, AI Context, and missing gate sections.")
|
|
951
|
-
parser.add_argument("--governance-profile", choices=tuple(GOVERNANCE_PROFILES), default="standard", help="Apply a named governance profile
|
|
1005
|
+
parser.add_argument("--governance-profile", choices=tuple(GOVERNANCE_PROFILES), default="standard", help="Apply a named governance profile; `standard` reports early companion-doc polish as warnings, `strict` promotes governance warnings to blockers.")
|
|
952
1006
|
return parser
|
|
953
1007
|
|
|
954
1008
|
|
package/logics_manager/cli.py
CHANGED
|
@@ -39,67 +39,48 @@ ROOT_COMMANDS = (
|
|
|
39
39
|
def _build_root_help() -> str:
|
|
40
40
|
sections = [
|
|
41
41
|
"Logics Manager CLI",
|
|
42
|
-
"Canonical CLI for workflow, validation, and runtime ops.",
|
|
42
|
+
"Canonical CLI for Logics workflow, validation, MCP, and runtime ops.",
|
|
43
43
|
"",
|
|
44
44
|
"Usage:",
|
|
45
45
|
" logics-manager <command> [args...]",
|
|
46
|
-
" logics-manager
|
|
46
|
+
" logics-manager <command> --help",
|
|
47
47
|
"",
|
|
48
48
|
"Top-level options:",
|
|
49
49
|
" -h, --help Show this help message and exit.",
|
|
50
|
-
" -v, --version Print the installed version.",
|
|
51
|
-
" --version Print the installed version.",
|
|
50
|
+
" -v, --version Print the installed version and exit.",
|
|
52
51
|
"",
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"",
|
|
58
|
-
" flow",
|
|
59
|
-
" Create and manage workflow docs.",
|
|
60
|
-
" Subcommands: new, list, companion, promote, split, close, finish",
|
|
61
|
-
" Key flags: --title, --slug, --from-version, --understanding, --confidence, --status, --complexity, --theme, --progress, --format {text,json}, --dry-run",
|
|
62
|
-
"",
|
|
63
|
-
" sync",
|
|
64
|
-
" Synchronize workflow transitions and exports.",
|
|
65
|
-
" Subcommands: close-eligible-requests, refresh-mermaid-signatures, schema-status, read-doc, list-docs, search-docs, update-indicators, append-note, context-pack, export-graph",
|
|
66
|
-
"",
|
|
67
|
-
" assist",
|
|
68
|
-
" Inspect runtime signals and build context bundles.",
|
|
69
|
-
" Subcommands: runtime-status, diff-risk, commit-plan, changed-surface-summary, doc-consistency, review-checklist, validation-checklist, validation-summary, test-impact-summary, roi-report, claude-bridges, context, claude-instructions, next-step, request-draft, spec-first-pass, backlog-groom, closure-summary",
|
|
70
|
-
"",
|
|
71
|
-
" audit",
|
|
72
|
-
" Audit request, backlog, and task consistency.",
|
|
73
|
-
" Options: --stale-days, --skip-ac-traceability, --skip-gates, --legacy-cutoff-version, --format {text,json}, --group-by-doc, --autofix-ac-traceability, --paths, --refs, --since-version, --token-hygiene, --autofix-structure, --governance-profile",
|
|
74
|
-
"",
|
|
75
|
-
" index",
|
|
76
|
-
" Generate `logics/INDEX.md` from the workflow corpus.",
|
|
77
|
-
" Options: --out, --format {text,json}",
|
|
78
|
-
"",
|
|
79
|
-
" lint",
|
|
80
|
-
" Lint workflow documents for filenames, headings, and indicators.",
|
|
81
|
-
" Options: --require-status, --format {text,json}",
|
|
82
|
-
"",
|
|
83
|
-
" config show",
|
|
84
|
-
" Render the merged runtime config.",
|
|
85
|
-
" Options: --format {text,json}",
|
|
52
|
+
"Common workflows:",
|
|
53
|
+
' logics-manager flow new request --title "My request"',
|
|
54
|
+
" logics-manager audit --group-by-doc",
|
|
55
|
+
" logics-manager sync refresh-mermaid-signatures",
|
|
56
|
+
" logics-manager mcp tunnel --repo-root . --port 8765",
|
|
86
57
|
"",
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
58
|
+
"Workflow authoring:",
|
|
59
|
+
" flow Create, promote, split, close, and finish workflow docs.",
|
|
60
|
+
" Subcommands: new, list, companion, promote, split, close, finish",
|
|
61
|
+
" sync Maintain generated workflow state and doc metadata.",
|
|
62
|
+
" Subcommands: close-eligible-requests, refresh-mermaid-signatures,",
|
|
63
|
+
" schema-status, read-doc, list-docs, search-docs,",
|
|
64
|
+
" update-indicators, append-note, context-pack, export-graph",
|
|
65
|
+
" index Generate logics/INDEX.md from the workflow corpus.",
|
|
90
66
|
"",
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
67
|
+
"Validation:",
|
|
68
|
+
" lint Check filenames, headings, indicators, and changed-doc hygiene.",
|
|
69
|
+
" audit Check workflow consistency and traceability.",
|
|
70
|
+
" Use --governance-profile {relaxed,standard,strict}.",
|
|
71
|
+
" JSON output includes issue_count, warning_count, can_continue,",
|
|
72
|
+
" and release_ready for agent workflows.",
|
|
73
|
+
" doctor Check required workflow directories and schema metadata.",
|
|
94
74
|
"",
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
75
|
+
"Agent and integration surfaces:",
|
|
76
|
+
" assist Inspect runtime signals and build bounded context bundles.",
|
|
77
|
+
" mcp Expose bounded Logics tools for MCP clients.",
|
|
78
|
+
" Subcommands: serve, serve-http, connect, tunnel, tools, call",
|
|
79
|
+
" config Render merged runtime config. Example: config show --format json",
|
|
98
80
|
"",
|
|
99
|
-
"
|
|
100
|
-
|
|
101
|
-
"
|
|
102
|
-
" logics-manager config show --format json",
|
|
81
|
+
"Maintenance:",
|
|
82
|
+
" bootstrap Prepare or check the workflow tree and generated instructions.",
|
|
83
|
+
" self-update Update the installed Python or npm package.",
|
|
103
84
|
]
|
|
104
85
|
return "\n".join(sections)
|
|
105
86
|
|
package/logics_manager/lint.py
CHANGED
|
@@ -578,12 +578,26 @@ def lint_payload(repo_root: Path, *, require_status: bool = False) -> dict[str,
|
|
|
578
578
|
if warnings:
|
|
579
579
|
all_warnings.append((rel_path, warnings))
|
|
580
580
|
|
|
581
|
+
issues = [{"path": path.as_posix(), "message": issue, "severity": "blocking"} for path, issues in all_issues for issue in issues]
|
|
582
|
+
warnings: list[dict[str, str]] = []
|
|
583
|
+
for path, path_warnings in all_warnings:
|
|
584
|
+
for warning in path_warnings:
|
|
585
|
+
item = {"path": path.as_posix(), "message": warning, "severity": "warning"}
|
|
586
|
+
if "Mermaid context signature" in warning:
|
|
587
|
+
item["repair_command"] = "logics-manager sync refresh-mermaid-signatures"
|
|
588
|
+
warnings.append(item)
|
|
581
589
|
return {
|
|
582
|
-
"ok": not
|
|
583
|
-
"
|
|
584
|
-
"
|
|
585
|
-
"
|
|
586
|
-
"
|
|
590
|
+
"ok": not issues,
|
|
591
|
+
"can_continue": not issues,
|
|
592
|
+
"release_ready": not issues and not warnings,
|
|
593
|
+
"issue_count": len(issues),
|
|
594
|
+
"warning_count": len(warnings),
|
|
595
|
+
"strict_count": 0,
|
|
596
|
+
"finding_count": len(issues) + len(warnings),
|
|
597
|
+
"issues": issues,
|
|
598
|
+
"warnings": warnings,
|
|
599
|
+
"strict": [],
|
|
600
|
+
"findings": [*issues, *warnings],
|
|
587
601
|
}
|
|
588
602
|
|
|
589
603
|
|
|
@@ -594,11 +608,11 @@ def render_lint(repo_root: Path, *, require_status: bool = False, output_format:
|
|
|
594
608
|
if not payload["issues"] and not payload["warnings"]:
|
|
595
609
|
return "Logics lint: OK"
|
|
596
610
|
if not payload["issues"]:
|
|
597
|
-
lines = ["Logics lint: OK (warnings)"]
|
|
611
|
+
lines = ["Logics lint: OK (warnings)", f"Blocking issues: {payload['issue_count']}; warnings: {payload['warning_count']}"]
|
|
598
612
|
for warning in payload["warnings"]:
|
|
599
613
|
lines.append(f"- {warning['path']}: WARNING: {warning['message']}")
|
|
600
614
|
return "\n".join(lines)
|
|
601
|
-
lines = ["Logics lint: FAILED"]
|
|
615
|
+
lines = ["Logics lint: FAILED", f"Blocking issues: {payload['issue_count']}; warnings: {payload['warning_count']}"]
|
|
602
616
|
for issue in payload["issues"]:
|
|
603
617
|
lines.append(f"- {issue['path']}: {issue['message']}")
|
|
604
618
|
for warning in payload["warnings"]:
|
package/logics_manager/mcp.py
CHANGED
|
@@ -8,8 +8,10 @@ import json
|
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
10
|
import secrets
|
|
11
|
+
import signal
|
|
11
12
|
import subprocess
|
|
12
13
|
import sys
|
|
14
|
+
import time
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
from typing import Any
|
|
15
17
|
from urllib.error import URLError
|
|
@@ -115,6 +117,17 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|
|
115
117
|
),
|
|
116
118
|
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
117
119
|
},
|
|
120
|
+
{
|
|
121
|
+
"name": "list_companion_docs",
|
|
122
|
+
"description": "List Logics companion documents such as product briefs and architecture decisions.",
|
|
123
|
+
"inputSchema": _tool_schema(
|
|
124
|
+
{
|
|
125
|
+
"kind": {"type": "string", "enum": ["all", "product", "architecture"]},
|
|
126
|
+
"limit": {"type": "integer"},
|
|
127
|
+
}
|
|
128
|
+
),
|
|
129
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
130
|
+
},
|
|
118
131
|
{
|
|
119
132
|
"name": "list_active_work",
|
|
120
133
|
"description": "List active Logics request, backlog, and task documents.",
|
|
@@ -277,6 +290,25 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|
|
277
290
|
"inputSchema": _tool_schema({"paths": {"type": "array", "items": {"type": "string"}}}),
|
|
278
291
|
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
279
292
|
},
|
|
293
|
+
{
|
|
294
|
+
"name": "delete_logics_file",
|
|
295
|
+
"description": "Delete one bounded Logics Markdown file from an approved Logics directory.",
|
|
296
|
+
"inputSchema": _tool_schema({"path": {"type": "string"}, "dry_run": {"type": "boolean"}}, ["path"]),
|
|
297
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": True},
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"name": "rename_logics_file",
|
|
301
|
+
"description": "Rename one bounded Logics Markdown file within approved Logics directories.",
|
|
302
|
+
"inputSchema": _tool_schema(
|
|
303
|
+
{
|
|
304
|
+
"source_path": {"type": "string"},
|
|
305
|
+
"destination_path": {"type": "string"},
|
|
306
|
+
"dry_run": {"type": "boolean"},
|
|
307
|
+
},
|
|
308
|
+
["source_path", "destination_path"],
|
|
309
|
+
),
|
|
310
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
311
|
+
},
|
|
280
312
|
]
|
|
281
313
|
TOOLS_BY_NAME = {str(tool["name"]): tool for tool in TOOL_DEFINITIONS}
|
|
282
314
|
|
|
@@ -354,6 +386,13 @@ def _relative_path(repo_root: Path, raw_path: str, allowed_dirs: tuple[str, ...]
|
|
|
354
386
|
return normalized
|
|
355
387
|
|
|
356
388
|
|
|
389
|
+
def _markdown_file_path(repo_root: Path, raw_path: str, allowed_dirs: tuple[str, ...] = ALLOWED_WRITE_DIRS) -> Path:
|
|
390
|
+
rel_path = _relative_path(repo_root, raw_path, allowed_dirs)
|
|
391
|
+
if rel_path.suffix != ".md":
|
|
392
|
+
raise McpToolError("invalid_path", "Only Markdown files are accepted.", details={"path": raw_path, "extension": rel_path.suffix})
|
|
393
|
+
return rel_path
|
|
394
|
+
|
|
395
|
+
|
|
357
396
|
def _run_command(repo_root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
358
397
|
command = [sys.executable, "-m", "logics_manager", *args]
|
|
359
398
|
result = subprocess.run(command, cwd=repo_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
@@ -511,8 +550,16 @@ def _audit_status(repo_root: Path) -> dict[str, Any]:
|
|
|
511
550
|
payload = audit_payload(repo_root, legacy_cutoff_version="1.1.0", group_by_doc=True)
|
|
512
551
|
return {
|
|
513
552
|
"ok": bool(payload.get("ok")),
|
|
553
|
+
"can_continue": bool(payload.get("can_continue", payload.get("ok"))),
|
|
554
|
+
"release_ready": bool(payload.get("release_ready", payload.get("ok"))),
|
|
514
555
|
"issue_count": payload.get("issue_count", 0),
|
|
556
|
+
"warning_count": payload.get("warning_count", 0),
|
|
557
|
+
"strict_count": payload.get("strict_count", 0),
|
|
558
|
+
"finding_count": payload.get("finding_count", payload.get("issue_count", 0)),
|
|
515
559
|
"issues": payload.get("issues", []),
|
|
560
|
+
"warnings": payload.get("warnings", []),
|
|
561
|
+
"strict": payload.get("strict", []),
|
|
562
|
+
"findings": payload.get("findings", payload.get("issues", [])),
|
|
516
563
|
"issues_by_doc": payload.get("issues_by_doc", {}),
|
|
517
564
|
}
|
|
518
565
|
|
|
@@ -597,6 +644,74 @@ def _document_preview(repo_root: Path, rel_path: str, *, max_chars: int = 1600)
|
|
|
597
644
|
}
|
|
598
645
|
|
|
599
646
|
|
|
647
|
+
def _indicator_from_lines(lines: list[str], key: str) -> str | None:
|
|
648
|
+
prefix = f"> {key}:"
|
|
649
|
+
for line in lines:
|
|
650
|
+
if line.startswith(prefix):
|
|
651
|
+
return line.split(":", 1)[1].strip()
|
|
652
|
+
return None
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _title_from_heading(lines: list[str], fallback: str) -> str:
|
|
656
|
+
for line in lines:
|
|
657
|
+
if not line.startswith("## "):
|
|
658
|
+
continue
|
|
659
|
+
heading = line[3:].strip()
|
|
660
|
+
if " - " in heading:
|
|
661
|
+
return heading.split(" - ", 1)[1].strip()
|
|
662
|
+
return heading
|
|
663
|
+
return fallback
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _parse_companion_refs(value: str | None) -> list[str]:
|
|
667
|
+
if not value or value == "(none yet)":
|
|
668
|
+
return []
|
|
669
|
+
refs = re.findall(r"`([^`]+)`", value)
|
|
670
|
+
if refs:
|
|
671
|
+
return [ref for ref in refs if ref not in {"(none)", "(none yet)"}]
|
|
672
|
+
return [part.strip() for part in value.split(",") if part.strip() and part.strip() not in {"(none)", "(none yet)"}]
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _companion_doc_entry(repo_root: Path, rel_path: Path, kind: str) -> dict[str, Any]:
|
|
676
|
+
path = repo_root / rel_path
|
|
677
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
678
|
+
related = {
|
|
679
|
+
"request": _parse_companion_refs(_indicator_from_lines(lines, "Related request")),
|
|
680
|
+
"backlog": _parse_companion_refs(_indicator_from_lines(lines, "Related backlog")),
|
|
681
|
+
"task": _parse_companion_refs(_indicator_from_lines(lines, "Related task")),
|
|
682
|
+
"architecture": _parse_companion_refs(_indicator_from_lines(lines, "Related architecture")),
|
|
683
|
+
}
|
|
684
|
+
return {
|
|
685
|
+
"kind": kind,
|
|
686
|
+
"ref": rel_path.stem,
|
|
687
|
+
"path": rel_path.as_posix(),
|
|
688
|
+
"title": _title_from_heading(lines, rel_path.stem),
|
|
689
|
+
"status": _indicator_from_lines(lines, "Status") or "Unknown",
|
|
690
|
+
"related": {key: refs for key, refs in related.items() if refs},
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _list_companion_docs(repo_root: Path, *, kind: str = "all", limit: int = 50) -> dict[str, Any]:
|
|
695
|
+
if kind not in {"all", "product", "architecture"}:
|
|
696
|
+
raise McpToolError("invalid_argument_value", "Unsupported companion document kind.", details={"kind": kind, "allowed": ["all", "product", "architecture"]})
|
|
697
|
+
targets = []
|
|
698
|
+
if kind in {"all", "product"}:
|
|
699
|
+
targets.append(("product", Path("logics/product"), "prod_*.md"))
|
|
700
|
+
if kind in {"all", "architecture"}:
|
|
701
|
+
targets.append(("architecture", Path("logics/architecture"), "adr_*.md"))
|
|
702
|
+
items: list[dict[str, Any]] = []
|
|
703
|
+
for doc_kind, directory, pattern in targets:
|
|
704
|
+
root = repo_root / directory
|
|
705
|
+
if not root.is_dir():
|
|
706
|
+
continue
|
|
707
|
+
for path in sorted(root.glob(pattern)):
|
|
708
|
+
if path.is_file() and not path.is_symlink():
|
|
709
|
+
items.append(_companion_doc_entry(repo_root, path.relative_to(repo_root), doc_kind))
|
|
710
|
+
items.sort(key=lambda item: str(item["path"]))
|
|
711
|
+
bounded_items = items[:limit]
|
|
712
|
+
return {"kind": kind, "limit": limit, "count": len(bounded_items), "total_count": len(items), "items": bounded_items}
|
|
713
|
+
|
|
714
|
+
|
|
600
715
|
def _bounded_int(value: Any, *, default: int, maximum: int) -> int:
|
|
601
716
|
if not isinstance(value, int) or isinstance(value, bool):
|
|
602
717
|
return default
|
|
@@ -659,6 +774,9 @@ def call_tool(name: str, arguments: dict[str, Any] | None = None, *, repo_root:
|
|
|
659
774
|
if kind not in {"all", "request", "backlog", "task"}:
|
|
660
775
|
raise McpToolError("invalid_argument_value", "Unsupported list kind.", details={"kind": kind, "allowed": ["all", "request", "backlog", "task"]})
|
|
661
776
|
return {"ok": True, "items": flow_list_payload(root, kind=kind)["entries"]}
|
|
777
|
+
if name == "list_companion_docs":
|
|
778
|
+
payload = _list_companion_docs(root, kind=str(args.get("kind") or "all"), limit=_bounded_int(args.get("limit"), default=50, maximum=200))
|
|
779
|
+
return {"ok": True, **payload}
|
|
662
780
|
if name == "read_logics_doc":
|
|
663
781
|
try:
|
|
664
782
|
payload = read_logics_doc_payload(root, str(args.get("source") or ""), max_chars=_bounded_int(args.get("max_chars"), default=4000, maximum=12000), sections=args.get("sections") if isinstance(args.get("sections"), list) else None)
|
|
@@ -813,6 +931,58 @@ def call_tool(name: str, arguments: dict[str, Any] | None = None, *, repo_root:
|
|
|
813
931
|
raw_paths = args.get("paths")
|
|
814
932
|
paths = [str(path) for path in raw_paths] if isinstance(raw_paths, list) else None
|
|
815
933
|
return _show_git_diff(root, paths)
|
|
934
|
+
if name == "delete_logics_file":
|
|
935
|
+
rel_path = _markdown_file_path(root, str(args.get("path") or ""))
|
|
936
|
+
target = root / rel_path
|
|
937
|
+
dry_run = bool(args.get("dry_run", False))
|
|
938
|
+
if not target.exists():
|
|
939
|
+
raise McpToolError("not_found", "Logics file not found.", details={"path": rel_path.as_posix()})
|
|
940
|
+
if not target.is_file() or target.is_symlink():
|
|
941
|
+
raise McpToolError("invalid_path", "Only regular Markdown files can be deleted.", details={"path": rel_path.as_posix()})
|
|
942
|
+
if not dry_run:
|
|
943
|
+
_ensure_no_dirty_conflict(root, [rel_path.as_posix()])
|
|
944
|
+
target.unlink()
|
|
945
|
+
return _workflow_write_result(
|
|
946
|
+
root,
|
|
947
|
+
{
|
|
948
|
+
"path": rel_path.as_posix(),
|
|
949
|
+
"dry_run": dry_run,
|
|
950
|
+
"deleted": not dry_run,
|
|
951
|
+
"would_delete": dry_run,
|
|
952
|
+
"summary": f"{'Would delete' if dry_run else 'Deleted'} {rel_path.as_posix()}",
|
|
953
|
+
},
|
|
954
|
+
paths=[rel_path.as_posix()],
|
|
955
|
+
)
|
|
956
|
+
if name == "rename_logics_file":
|
|
957
|
+
source_rel = _markdown_file_path(root, str(args.get("source_path") or ""))
|
|
958
|
+
destination_rel = _markdown_file_path(root, str(args.get("destination_path") or ""))
|
|
959
|
+
source = root / source_rel
|
|
960
|
+
destination = root / destination_rel
|
|
961
|
+
dry_run = bool(args.get("dry_run", False))
|
|
962
|
+
if source_rel == destination_rel:
|
|
963
|
+
raise McpToolError("invalid_path", "Source and destination paths must differ.", details={"source_path": source_rel.as_posix(), "destination_path": destination_rel.as_posix()})
|
|
964
|
+
if not source.exists():
|
|
965
|
+
raise McpToolError("not_found", "Source Logics file not found.", details={"source_path": source_rel.as_posix()})
|
|
966
|
+
if not source.is_file() or source.is_symlink():
|
|
967
|
+
raise McpToolError("invalid_path", "Only regular Markdown files can be renamed.", details={"source_path": source_rel.as_posix()})
|
|
968
|
+
if destination.exists():
|
|
969
|
+
raise McpToolError("already_exists", "Destination already exists.", details={"destination_path": destination_rel.as_posix()})
|
|
970
|
+
if not dry_run:
|
|
971
|
+
_ensure_no_dirty_conflict(root, [source_rel.as_posix(), destination_rel.as_posix()])
|
|
972
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
973
|
+
source.rename(destination)
|
|
974
|
+
return _workflow_write_result(
|
|
975
|
+
root,
|
|
976
|
+
{
|
|
977
|
+
"source_path": source_rel.as_posix(),
|
|
978
|
+
"destination_path": destination_rel.as_posix(),
|
|
979
|
+
"dry_run": dry_run,
|
|
980
|
+
"renamed": not dry_run,
|
|
981
|
+
"would_rename": dry_run,
|
|
982
|
+
"summary": f"{'Would rename' if dry_run else 'Renamed'} {source_rel.as_posix()} to {destination_rel.as_posix()}",
|
|
983
|
+
},
|
|
984
|
+
paths=[source_rel.as_posix(), destination_rel.as_posix()],
|
|
985
|
+
)
|
|
816
986
|
|
|
817
987
|
if name == "create_request":
|
|
818
988
|
title = str(args.get("title") or "").strip()
|
|
@@ -969,11 +1139,48 @@ def make_http_handler(repo_root: Path, *, bearer_token: str | None = None) -> ty
|
|
|
969
1139
|
self.end_headers()
|
|
970
1140
|
self.wfile.write(encoded)
|
|
971
1141
|
|
|
1142
|
+
def _authorized(self) -> bool:
|
|
1143
|
+
if not bearer_token:
|
|
1144
|
+
return True
|
|
1145
|
+
expected = f"Bearer {bearer_token}"
|
|
1146
|
+
actual = self.headers.get("Authorization", "")
|
|
1147
|
+
if secrets.compare_digest(actual, expected):
|
|
1148
|
+
return True
|
|
1149
|
+
self.send_response(401)
|
|
1150
|
+
self.send_header("Content-Type", "application/json")
|
|
1151
|
+
self.send_header("WWW-Authenticate", 'Bearer realm="logics-mcp"')
|
|
1152
|
+
encoded = json.dumps({"ok": False, "error": "unauthorized", "message": "Missing or invalid bearer token."}, separators=(",", ":")).encode("utf-8")
|
|
1153
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
1154
|
+
self.end_headers()
|
|
1155
|
+
self.wfile.write(encoded)
|
|
1156
|
+
return False
|
|
1157
|
+
|
|
1158
|
+
def _send_sse_stream(self) -> None:
|
|
1159
|
+
self.send_response(200)
|
|
1160
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
1161
|
+
self.send_header("Cache-Control", "no-cache")
|
|
1162
|
+
self.send_header("Connection", "keep-alive")
|
|
1163
|
+
self.end_headers()
|
|
1164
|
+
self.wfile.write(b": logics-manager-mcp ready\n\n")
|
|
1165
|
+
self.wfile.flush()
|
|
1166
|
+
while True:
|
|
1167
|
+
time.sleep(15)
|
|
1168
|
+
self.wfile.write(b": keepalive\n\n")
|
|
1169
|
+
self.wfile.flush()
|
|
1170
|
+
|
|
972
1171
|
def do_GET(self) -> None:
|
|
973
1172
|
parsed = urlparse(self.path)
|
|
974
1173
|
if parsed.path == "/health":
|
|
975
1174
|
self._send_json(200, {"ok": True, "server": "logics-manager-mcp", "version": _server_version()})
|
|
976
1175
|
return
|
|
1176
|
+
if parsed.path == "/mcp":
|
|
1177
|
+
if not self._authorized():
|
|
1178
|
+
return
|
|
1179
|
+
try:
|
|
1180
|
+
self._send_sse_stream()
|
|
1181
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
1182
|
+
return
|
|
1183
|
+
return
|
|
977
1184
|
self._send_json(404, {"ok": False, "error": "not_found", "message": "Use POST /mcp for JSON-RPC."})
|
|
978
1185
|
|
|
979
1186
|
def do_POST(self) -> None:
|
|
@@ -981,18 +1188,8 @@ def make_http_handler(repo_root: Path, *, bearer_token: str | None = None) -> ty
|
|
|
981
1188
|
if parsed.path != "/mcp":
|
|
982
1189
|
self._send_json(404, {"ok": False, "error": "not_found", "message": "Use POST /mcp for JSON-RPC."})
|
|
983
1190
|
return
|
|
984
|
-
if
|
|
985
|
-
|
|
986
|
-
actual = self.headers.get("Authorization", "")
|
|
987
|
-
if not secrets.compare_digest(actual, expected):
|
|
988
|
-
self.send_response(401)
|
|
989
|
-
self.send_header("Content-Type", "application/json")
|
|
990
|
-
self.send_header("WWW-Authenticate", 'Bearer realm="logics-mcp"')
|
|
991
|
-
encoded = json.dumps({"ok": False, "error": "unauthorized", "message": "Missing or invalid bearer token."}, separators=(",", ":")).encode("utf-8")
|
|
992
|
-
self.send_header("Content-Length", str(len(encoded)))
|
|
993
|
-
self.end_headers()
|
|
994
|
-
self.wfile.write(encoded)
|
|
995
|
-
return
|
|
1191
|
+
if not self._authorized():
|
|
1192
|
+
return
|
|
996
1193
|
try:
|
|
997
1194
|
length = int(self.headers.get("Content-Length", "0"))
|
|
998
1195
|
except ValueError:
|
|
@@ -1051,17 +1248,29 @@ def _connector_urls(public_url: str | None) -> dict[str, str]:
|
|
|
1051
1248
|
return {"public_url": base, "mcp_url": mcp_url, "health_url": health_url}
|
|
1052
1249
|
|
|
1053
1250
|
|
|
1054
|
-
def connector_plan(*, repo_root: Path, host: str, port: int, bearer_token: str | None = None, public_url: str | None = None) -> dict[str, Any]:
|
|
1055
|
-
token = bearer_token or secrets.token_urlsafe(32)
|
|
1251
|
+
def connector_plan(*, repo_root: Path, host: str, port: int, bearer_token: str | None = None, public_url: str | None = None, no_bearer: bool = False, project_binary: str | None = None) -> dict[str, Any]:
|
|
1252
|
+
token = None if no_bearer else bearer_token or secrets.token_urlsafe(32)
|
|
1056
1253
|
urls = _connector_urls(public_url)
|
|
1057
1254
|
local_mcp_url = f"http://{host}:{port}/mcp"
|
|
1058
1255
|
local_health_url = f"http://{host}:{port}/health"
|
|
1059
|
-
|
|
1256
|
+
launcher = project_binary or "python3 -m logics_manager"
|
|
1257
|
+
auth_args = "" if no_bearer else f'{AUTH_ENV_VAR}="{token}" '
|
|
1258
|
+
server_command = f"{auth_args}{launcher} mcp serve-http --repo-root {repo_root.as_posix()} --host {host} --port {port}"
|
|
1259
|
+
auth_header = None if no_bearer else f"Authorization: Bearer {token}"
|
|
1260
|
+
cleanup = [
|
|
1261
|
+
"Stop the HTTPS tunnel process.",
|
|
1262
|
+
"Stop the local mcp serve-http process with Ctrl-C.",
|
|
1263
|
+
]
|
|
1264
|
+
if token:
|
|
1265
|
+
cleanup.append("Treat the bearer token as expired once the local session is stopped.")
|
|
1266
|
+
else:
|
|
1267
|
+
cleanup.append("Treat the public tunnel URL as exposed until both processes are stopped.")
|
|
1060
1268
|
return {
|
|
1061
1269
|
"ok": True,
|
|
1062
1270
|
"repo_root": repo_root.as_posix(),
|
|
1063
1271
|
"bearer_token": token,
|
|
1064
|
-
"
|
|
1272
|
+
"auth_mode": "none" if no_bearer else "bearer",
|
|
1273
|
+
"auth_header": auth_header,
|
|
1065
1274
|
"local_mcp_url": local_mcp_url,
|
|
1066
1275
|
"local_health_url": local_health_url,
|
|
1067
1276
|
"server_command": server_command,
|
|
@@ -1069,23 +1278,20 @@ def connector_plan(*, repo_root: Path, host: str, port: int, bearer_token: str |
|
|
|
1069
1278
|
"chatgpt": {
|
|
1070
1279
|
"developer_mode": True,
|
|
1071
1280
|
"mcp_url": urls.get("mcp_url", "<your HTTPS tunnel URL>/mcp"),
|
|
1072
|
-
"auth_type": "Bearer token",
|
|
1281
|
+
"auth_type": "None" if no_bearer else "Bearer token",
|
|
1073
1282
|
"auth_value": token,
|
|
1074
1283
|
},
|
|
1075
1284
|
"smoke_checks": {
|
|
1076
1285
|
"health": urls.get("health_url", f"<your HTTPS tunnel URL>/health"),
|
|
1077
1286
|
"mcp_tools_list": urls.get("mcp_url", "<your HTTPS tunnel URL>/mcp"),
|
|
1078
1287
|
},
|
|
1079
|
-
"
|
|
1080
|
-
|
|
1081
|
-
"Stop the local mcp serve-http process with Ctrl-C.",
|
|
1082
|
-
"Treat the bearer token as expired once the local session is stopped.",
|
|
1083
|
-
],
|
|
1288
|
+
"warnings": ["No-bearer mode is unauthenticated. Use only for short-lived local debugging."] if no_bearer else [],
|
|
1289
|
+
"cleanup": cleanup,
|
|
1084
1290
|
**urls,
|
|
1085
1291
|
}
|
|
1086
1292
|
|
|
1087
1293
|
|
|
1088
|
-
def connector_smoke_check(public_url: str, bearer_token: str, *, timeout: float = 5.0) -> dict[str, Any]:
|
|
1294
|
+
def connector_smoke_check(public_url: str, bearer_token: str | None = None, *, timeout: float = 5.0) -> dict[str, Any]:
|
|
1089
1295
|
urls = _connector_urls(public_url)
|
|
1090
1296
|
health_ok = False
|
|
1091
1297
|
mcp_ok = False
|
|
@@ -1097,7 +1303,10 @@ def connector_smoke_check(public_url: str, bearer_token: str, *, timeout: float
|
|
|
1097
1303
|
errors.append(f"health: {exc}")
|
|
1098
1304
|
try:
|
|
1099
1305
|
body = json.dumps({"jsonrpc": JSONRPC_VERSION, "id": 1, "method": "tools/list", "params": {}}).encode("utf-8")
|
|
1100
|
-
|
|
1306
|
+
headers = {"Content-Type": "application/json"}
|
|
1307
|
+
if bearer_token:
|
|
1308
|
+
headers["Authorization"] = f"Bearer {bearer_token}"
|
|
1309
|
+
request = Request(urls["mcp_url"], data=body, headers=headers, method="POST")
|
|
1101
1310
|
with urlopen(request, timeout=timeout) as response:
|
|
1102
1311
|
payload = json.loads(response.read().decode("utf-8"))
|
|
1103
1312
|
mcp_ok = response.status == 200 and "result" in payload
|
|
@@ -1108,10 +1317,13 @@ def connector_smoke_check(public_url: str, bearer_token: str, *, timeout: float
|
|
|
1108
1317
|
|
|
1109
1318
|
def _print_connector_plan(plan: dict[str, Any]) -> None:
|
|
1110
1319
|
print("Logics MCP Connector")
|
|
1320
|
+
for warning in plan.get("warnings", []):
|
|
1321
|
+
print(f"WARNING: {warning}")
|
|
1111
1322
|
print(f"Server command:\n {plan['server_command']}")
|
|
1112
1323
|
print(f"Tunnel target: {plan['tunnel_target']}")
|
|
1113
1324
|
print(f"ChatGPT developer-mode MCP URL: {plan['chatgpt']['mcp_url']}")
|
|
1114
|
-
print(f"
|
|
1325
|
+
print(f"Auth mode: {plan['auth_mode']}")
|
|
1326
|
+
print(f"Authorization header: {plan['auth_header'] or '(none)'}")
|
|
1115
1327
|
print("Smoke checks:")
|
|
1116
1328
|
print(f" health: {plan['smoke_checks']['health']}")
|
|
1117
1329
|
print(f" mcp tools/list: {plan['smoke_checks']['mcp_tools_list']}")
|
|
@@ -1120,6 +1332,88 @@ def _print_connector_plan(plan: dict[str, Any]) -> None:
|
|
|
1120
1332
|
print(f" - {item}")
|
|
1121
1333
|
|
|
1122
1334
|
|
|
1335
|
+
def _project_binary_path(repo_root: Path) -> str:
|
|
1336
|
+
candidate = repo_root / "scripts" / "npm" / "logics-manager.mjs"
|
|
1337
|
+
if candidate.is_file():
|
|
1338
|
+
return f"node {candidate.as_posix()}"
|
|
1339
|
+
return "python3 -m logics_manager"
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _terminate_process(process: subprocess.Popen[str]) -> None:
|
|
1343
|
+
if process.poll() is not None:
|
|
1344
|
+
return
|
|
1345
|
+
process.terminate()
|
|
1346
|
+
try:
|
|
1347
|
+
process.wait(timeout=5)
|
|
1348
|
+
except subprocess.TimeoutExpired:
|
|
1349
|
+
process.kill()
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def launch_tunnel(
|
|
1353
|
+
*,
|
|
1354
|
+
repo_root: Path,
|
|
1355
|
+
host: str,
|
|
1356
|
+
port: int,
|
|
1357
|
+
bearer_token: str | None = None,
|
|
1358
|
+
no_bearer: bool = False,
|
|
1359
|
+
tunnel_command: list[str] | None = None,
|
|
1360
|
+
) -> int:
|
|
1361
|
+
token = None if no_bearer else bearer_token or secrets.token_urlsafe(32)
|
|
1362
|
+
server_command = [sys.executable, "-m", "logics_manager", "mcp", "serve-http", "--repo-root", repo_root.as_posix(), "--host", host, "--port", str(port)]
|
|
1363
|
+
env = os.environ.copy()
|
|
1364
|
+
if token:
|
|
1365
|
+
env[AUTH_ENV_VAR] = token
|
|
1366
|
+
tunnel_command = tunnel_command or ["npx", "localtunnel", "--port", str(port)]
|
|
1367
|
+
server = subprocess.Popen(server_command, cwd=repo_root, env=env, text=True)
|
|
1368
|
+
tunnel: subprocess.Popen[str] | None = None
|
|
1369
|
+
previous_sigint = signal.getsignal(signal.SIGINT)
|
|
1370
|
+
previous_sigterm = signal.getsignal(signal.SIGTERM)
|
|
1371
|
+
|
|
1372
|
+
def stop(_signum: int | None = None, _frame: Any | None = None) -> None:
|
|
1373
|
+
if tunnel is not None:
|
|
1374
|
+
_terminate_process(tunnel)
|
|
1375
|
+
_terminate_process(server)
|
|
1376
|
+
|
|
1377
|
+
signal.signal(signal.SIGINT, stop)
|
|
1378
|
+
signal.signal(signal.SIGTERM, stop)
|
|
1379
|
+
try:
|
|
1380
|
+
time.sleep(0.8)
|
|
1381
|
+
if server.poll() is not None:
|
|
1382
|
+
return server.returncode or 1
|
|
1383
|
+
tunnel = subprocess.Popen(tunnel_command, cwd=repo_root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
1384
|
+
public_url = None
|
|
1385
|
+
start = time.monotonic()
|
|
1386
|
+
while time.monotonic() - start < 30:
|
|
1387
|
+
if tunnel.poll() is not None:
|
|
1388
|
+
return tunnel.returncode or 1
|
|
1389
|
+
line = tunnel.stdout.readline() if tunnel.stdout else ""
|
|
1390
|
+
if not line:
|
|
1391
|
+
time.sleep(0.1)
|
|
1392
|
+
continue
|
|
1393
|
+
print(line.rstrip())
|
|
1394
|
+
match = re.search(r"https://\S+", line)
|
|
1395
|
+
if match:
|
|
1396
|
+
public_url = match.group(0).rstrip("/")
|
|
1397
|
+
break
|
|
1398
|
+
if not public_url:
|
|
1399
|
+
raise McpToolError("command_failed", "Tunnel command did not print a public HTTPS URL within 30 seconds.", details={"command": tunnel_command})
|
|
1400
|
+
plan = connector_plan(repo_root=repo_root, host=host, port=port, bearer_token=token, public_url=public_url, no_bearer=no_bearer, project_binary=_project_binary_path(repo_root))
|
|
1401
|
+
_print_connector_plan(plan)
|
|
1402
|
+
print("Processes are running. Press Ctrl-C to stop server and tunnel.")
|
|
1403
|
+
while True:
|
|
1404
|
+
if server.poll() is not None:
|
|
1405
|
+
return server.returncode or 1
|
|
1406
|
+
if tunnel.poll() is not None:
|
|
1407
|
+
return tunnel.returncode or 1
|
|
1408
|
+
time.sleep(1)
|
|
1409
|
+
except KeyboardInterrupt:
|
|
1410
|
+
return 130
|
|
1411
|
+
finally:
|
|
1412
|
+
stop()
|
|
1413
|
+
signal.signal(signal.SIGINT, previous_sigint)
|
|
1414
|
+
signal.signal(signal.SIGTERM, previous_sigterm)
|
|
1415
|
+
|
|
1416
|
+
|
|
1123
1417
|
def main(argv: list[str] | None = None) -> int:
|
|
1124
1418
|
parser = argparse.ArgumentParser(prog="logics-manager mcp", description="Run or inspect the Logics MCP server.")
|
|
1125
1419
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -1141,9 +1435,16 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1141
1435
|
connect.add_argument("--host", default="127.0.0.1")
|
|
1142
1436
|
connect.add_argument("--port", type=int, default=8765)
|
|
1143
1437
|
connect.add_argument("--bearer-token", default=None)
|
|
1438
|
+
connect.add_argument("--no-bearer", action="store_true", help="Print a no-auth connector plan for short-lived local debugging.")
|
|
1144
1439
|
connect.add_argument("--public-url", default=None, help="Optional HTTPS tunnel URL used for copyable ChatGPT setup and smoke checks.")
|
|
1145
1440
|
connect.add_argument("--check", action="store_true", help="Run /health and authenticated /mcp smoke checks against --public-url.")
|
|
1146
1441
|
connect.add_argument("--format", choices=("text", "json"), default="text")
|
|
1442
|
+
tunnel = sub.add_parser("tunnel", help="Start the local MCP HTTP server plus an HTTPS localtunnel session.")
|
|
1443
|
+
tunnel.add_argument("--repo-root", default=None)
|
|
1444
|
+
tunnel.add_argument("--host", default="127.0.0.1")
|
|
1445
|
+
tunnel.add_argument("--port", type=int, default=8765)
|
|
1446
|
+
tunnel.add_argument("--bearer-token", default=None)
|
|
1447
|
+
tunnel.add_argument("--no-bearer", action="store_true", help="Run without bearer auth for short-lived local debugging.")
|
|
1147
1448
|
parsed = parser.parse_args(argv)
|
|
1148
1449
|
|
|
1149
1450
|
if parsed.command == "tools":
|
|
@@ -1170,11 +1471,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1170
1471
|
return 0
|
|
1171
1472
|
if parsed.command == "connect":
|
|
1172
1473
|
root = _repo_root(Path(parsed.repo_root) if parsed.repo_root else None)
|
|
1173
|
-
|
|
1474
|
+
if parsed.no_bearer and parsed.bearer_token:
|
|
1475
|
+
raise SystemExit("--no-bearer cannot be combined with --bearer-token.")
|
|
1476
|
+
plan = connector_plan(repo_root=root, host=parsed.host, port=parsed.port, bearer_token=parsed.bearer_token, public_url=parsed.public_url, no_bearer=parsed.no_bearer, project_binary=_project_binary_path(root))
|
|
1174
1477
|
if parsed.check:
|
|
1175
1478
|
if not parsed.public_url:
|
|
1176
1479
|
raise SystemExit("--check requires --public-url.")
|
|
1177
|
-
plan["check"] = connector_smoke_check(parsed.public_url, str(plan["bearer_token"]))
|
|
1480
|
+
plan["check"] = connector_smoke_check(parsed.public_url, str(plan["bearer_token"]) if plan["bearer_token"] else None)
|
|
1178
1481
|
plan["ok"] = bool(plan["check"]["ok"])
|
|
1179
1482
|
if parsed.format == "json":
|
|
1180
1483
|
print(json.dumps(plan, indent=2, sort_keys=True))
|
|
@@ -1185,4 +1488,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1185
1488
|
for error in plan["check"]["errors"]:
|
|
1186
1489
|
print(f" - {error}")
|
|
1187
1490
|
return 0 if plan["ok"] else 1
|
|
1491
|
+
if parsed.command == "tunnel":
|
|
1492
|
+
if parsed.no_bearer and parsed.bearer_token:
|
|
1493
|
+
raise SystemExit("--no-bearer cannot be combined with --bearer-token.")
|
|
1494
|
+
root = _repo_root(Path(parsed.repo_root) if parsed.repo_root else None)
|
|
1495
|
+
return launch_tunnel(repo_root=root, host=parsed.host, port=parsed.port, bearer_token=parsed.bearer_token, no_bearer=parsed.no_bearer)
|
|
1188
1496
|
return 1
|
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.1.
|
|
5
|
+
"version": "2.1.2",
|
|
6
6
|
"publisher": "cdx-logics",
|
|
7
7
|
"icon": "media/icon.png",
|
|
8
8
|
"repository": {
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
"lint:es": "eslint src/**/*.ts",
|
|
131
131
|
"lint:logics": "node scripts/run-python.mjs -m logics_manager lint",
|
|
132
132
|
"audit:logics": "node scripts/run-python.mjs -m logics_manager audit && node scripts/run-python.mjs -m logics_manager lint",
|
|
133
|
+
"audit:logics:strict": "node scripts/run-python.mjs -m logics_manager audit --governance-profile strict && node scripts/run-python.mjs -m logics_manager lint --require-status",
|
|
133
134
|
"audit:ci": "node scripts/check-npm-audit.mjs",
|
|
134
135
|
"logics:finish:task": "node scripts/run-python.mjs -m logics_manager flow finish task",
|
|
135
136
|
"ci:fast": "npm run compile && npm run lint && npm run test:coverage && npm run test:smoke && npm run lint:logics && npm run package:ci",
|
package/pyproject.toml
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
3
4
|
import { dirname, resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
6
|
|
|
@@ -91,6 +92,17 @@ export function runLogicsManager(argv = process.argv.slice(2), platform = proces
|
|
|
91
92
|
return 1;
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
export function isDirectInvocation(importUrl = import.meta.url, argvPath = process.argv[1]) {
|
|
96
|
+
if (!argvPath) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return importUrl === pathToFileURL(realpathSync(argvPath)).href;
|
|
101
|
+
} catch {
|
|
102
|
+
return importUrl === pathToFileURL(argvPath).href;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (isDirectInvocation()) {
|
|
95
107
|
process.exit(runLogicsManager());
|
|
96
108
|
}
|