@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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/AlexAgo83/logics-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexAgo83/logics-manager/actions/workflows/ci.yml)
4
4
  [![License](https://img.shields.io/github/license/AlexAgo83/logics-manager)](LICENSE)
5
- ![Version](https://img.shields.io/badge/version-v2.1.1-4C8BF5)
5
+ ![Version](https://img.shields.io/badge/version-v2.1.2-4C8BF5)
6
6
  ![VS Code](https://img.shields.io/badge/VS%20Code-1.86.0-007ACC?logo=visualstudiocode&logoColor=white)
7
7
  ![TypeScript](https://img.shields.io/badge/TypeScript-5.3.3-3178C6?logo=typescript&logoColor=white)
8
8
  ![Vitest](https://img.shields.io/badge/Vitest-2.1.8-6E9F18?logo=vitest&logoColor=white)
@@ -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
- The connector plan prints the bearer token, server command, tunnel target, assistant connector URL, auth header, smoke checks, and cleanup steps.
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
1
+ 2.1.2
@@ -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
- serialized: list[dict[str, str]] = []
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
- serialized.append({"code": issue.code, "path": rel_path, "message": issue.message})
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 sorted_issues,
862
- "issue_count": len(sorted_issues),
863
- "issues": serialized,
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
- lines = ["Workflow audit: OK" if payload["ok"] else "Workflow audit: FAILED", f"Workflow docs inspected: {payload['workflow_doc_count']}"]
913
- issues = payload["issues"]
914
- if not issues:
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 issues:
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 issues:
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
- lines.append(f" - [{issue['code']}] {issue['message']}")
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 when resolving default audit strictness.")
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
 
@@ -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 config show [options]",
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
- "Commands:",
54
- " bootstrap",
55
- " Prepare or check the workflow tree and generated instructions.",
56
- " Options: --check, --format {text,json}",
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
- " doctor",
88
- " Check required workflow directories and schema metadata.",
89
- " Options: --format {text,json}",
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
- " mcp",
92
- " Expose bounded Logics tools for MCP clients.",
93
- " Subcommands: serve, serve-http, connect, tools, call",
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
- " self-update",
96
- " Update the installed Python or npm package.",
97
- " Options: --manager {auto,pip,npm}, --package, --python-package, --dry-run",
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
- "Examples:",
100
- ' logics-manager flow new request --title "My request"',
101
- " logics-manager audit",
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
 
@@ -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 all_issues,
583
- "issue_count": sum(len(issues) for _path, issues in all_issues),
584
- "warning_count": sum(len(warnings) for _path, warnings in all_warnings),
585
- "issues": [{"path": path.as_posix(), "message": issue} for path, issues in all_issues for issue in issues],
586
- "warnings": [{"path": path.as_posix(), "message": warning} for path, warnings in all_warnings for warning in warnings],
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"]:
@@ -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 bearer_token:
985
- expected = f"Bearer {bearer_token}"
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
- server_command = f'{AUTH_ENV_VAR}="{token}" python3 -m logics_manager mcp serve-http --repo-root {repo_root.as_posix()} --host {host} --port {port}'
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
- "auth_header": f"Authorization: Bearer {token}",
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
- "cleanup": [
1080
- "Stop the HTTPS tunnel process.",
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
- request = Request(urls["mcp_url"], data=body, headers={"Content-Type": "application/json", "Authorization": f"Bearer {bearer_token}"}, method="POST")
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"Authorization header: {plan['auth_header']}")
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
- plan = connector_plan(repo_root=root, host=parsed.host, port=parsed.port, bearer_token=parsed.bearer_token, public_url=parsed.public_url)
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.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "logics-manager"
7
- version = "2.1.1"
7
+ version = "2.1.2"
8
8
  description = "Canonical Logics CLI"
9
9
  requires-python = ">=3.10"
10
10
 
@@ -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
- if (import.meta.url === pathToFileURL(process.argv[1]).href) {
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
  }